My Logo

PUBLISHED OCTOBER 01, 2024

Teghen Donald Ticha
Lastly Updated: 10 days ago
Reading time 11 mins

A Beginner's Guide to Eager vs Lazy Evaluation in JavaScript

Discover the key differences between eager and lazy evaluation in JavaScript. Learn how to optimize function execution and explore functional libraries like Ramda.js and Lodash.js.
A Beginner's Guide to Eager vs Lazy Evaluation in JavaScript

Prerequisite

Before diving into our subject matter, it's helpful to have a basic understanding of:

  • JavaScript fundamentals (functions, variables, and control flow).
  • Higher-order functions like map, filter, and reduce.
  • Basic knowledge of functional programming concepts (optional, but beneficial).
We should forget about small efficiencies, say about 97% of the time ... premature optimization is the root of all evil. Yet we should not pass up our opportunities in that critical 3%.

Donald Knuth, The Art of Computer Programming



Always optimize last, or so they say!

First we learn, then we decide!

Some Crazy Guy, Thinking of loud


As a javaScript developer, understanding `how` and `when` your code gets evaluated can help you write more efficient and predictable code.

JavaScript predominantly uses `eager evaluation`, but you can simulate `lazy evaluation` in specific cases.

In this beginner guide, we'll attempt (hopefully to some degree of success🤓) to break down these concepts, providing plenty of code examples to ensure clarity facilitate the digestion.



Function Evaluation Styles in JavaScript

There are two primary ways in which a language can evaluate function arguments:

  1. 1.Eager (Strict) Evaluation: All function arguments are evaluated as soon as the function is invoked, whether they are used or not.
  2. 2.Lazy (Non-Strict) Evaluation: Function arguments are evaluated only when they are needed inside the function body.

Let’s go over both with clear examples to illustrate the differences.

Eager Evaluation

Eager evaluation, which is JavaScript's default behavior, means that when you call a function, all arguments are evaluated immediately.

Even if certain arguments aren't used within the function, JavaScript will still execute them.

Let's look at the example below:

function eagerEval(a, b) {
    console.log("Function executed!");
    return a + b;
}
const result = eagerEval(5, console.log("Evaluating b")); // Outputs: 
                                                                // Evaluating b
                                                                // Function executed!

Let's break it down:

  • The eagerEvaluation function takes two arguments, a and b.
  • When the function is called, the second argument is console.log("Evaluating b"). This is executed immediately, even before the function itself runs.
  • So, you first see "Evaluating b" in the output, followed by "Function executed!".


Let's have another example, but this time we'll use a function that take two params but only uses one of them:

function eagerEval(a, b) {
    console.log("Function executed!");
    return a;  // Notice we don't use 'b' at all
}
const result = eagerEval(5, console.log("Evaluating b")); // Outputs: 
                                                                // Evaluating b
                                                                // Function executed!

As you can see, our function still evaluate b, even though it’s not used in the function’s result.

This shows that in eager evaluation, arguments are `always` executed, regardless of whether the function needs them or not.



Lazy Evaluation

In contrast to eager evaluation, lazy evaluation delays the execution of function arguments until they are actually needed.

JavaScript does not have native support for lazy evaluation, but we can `mimic` it using techniques like `functions as arguments` (also known as `function wrapping`).

Let's have a quick example

To simulate lazy evaluation in JavaScript, we can pass the arguments as functions (thunks), and only call them when necessary.

function lazyEval(a, b) {
    console.log("Function executed first!");
    return a + b();
}
  
const result = lazyEval(5, () => console.log("Evaluating b"));   // Outputs: 
                                                                // Function executed first!
                                                                // Evaluating b

Breakdown: In this example, b is passed as a function () => console.log("Evaluating b").


This delays the execution of b until it's explicitly invoked inside the function with b().


"Evaluating b"
is only printed when b() is called, not when the function is initially invoked.


Let's repeat the above example but this time without using the second param at all.

function lazyEval(a, b) {
    console.log("Function executed!");
    return a;
}
const result = lazyEval(5, () => console.log("Evaluating b"));   // Outputs: 
                                                                // Function executed! 

Here, b is never called, so "Evaluating b" is not printed.


This shows that lazy evaluation avoids unnecessary computations, improving performance when dealing with expensive operations.



Why is Understanding Eager and Lazy Evaluation Important?

Knowing the difference between eager and lazy evaluation can help you:

  • Optimize performance: With lazy evaluation, expensive operations are delayed until absolutely necessary.
  • Control when code executes: Lazy evaluation gives you more flexibility by deferring execution.
  • Avoid unnecessary side effects: In eager evaluation, code is executed immediately, which could lead to side effects or unnecessary computations.


In the coming sections, we will dive deeper into each execution style, providing more concrete examples.

Some Characteristics of Eager Evaluation
  • Immediate Execution: Arguments are evaluated upfront as soon as the function is called.
  • No Optimization for Unused Arguments: All arguments are evaluated, regardless of whether they are used inside the function or not.
  • Potential Performance Issues: For expensive operations (e.g., API calls, complex computations), eager evaluation can lead to unnecessary overhead.

Let’s look at a few concrete real-world scenarios where eager evaluation might affect your code.


1. API Request in Eager Evaluation
Imagine you have a function that fetches data from an external API only when the cached data is null.


With eager evaluation, even if the request isn’t needed, it will still be executed.

const dataFetcher = () => {
    console.log('fetching your data!');
    return new Promise(resolve => {
        resolve({id: 1001, name: "Test User"})
    })
}

function getUserProfile(userId, fetchData) {
    const cachedUser = { id: userId, name: "Jane Doe" };
    if (cachedUser) {
        return cachedUser;
    }
    return fetchData();
  }
  
  const data = getUserProfile(123, dataFetcher());
  console.log('data: ', data); 
// outputs:
//fetching your data!
//data:  { id: 123, name: 'Jane Doe' }

This leads to a waste of resources as clearly, that extra computation and io operation aren't need.



2. Consider that you’re transforming data but only want to perform heavy computations if certain conditions are met.


With eager evaluation, those computations will run regardless of whether they are needed.

const calculateDiscount = () => {
    console.log('Calculating discount...');
    return {disocunt: 300};
}

const applyPromoCode = () => {
    console.log('Applying Promo...');
    return {disocunt: 120};
}


function processOrder(order, calculateDiscount, applyPromoCode) {
    if (!order.promoApplied) {
      return order;
    }
    return {
      ...order,
      discount: calculateDiscount(),
      promoCode: applyPromoCode(),
    };
  }
  
  const orderDetails = processOrder(
    { id: 1, amount: 100, promoApplied: false },
    calculateDiscount(),
    applyPromoCode()
  );

console.log('orderDetails', orderDetails);

The calculateDiscount() and applyPromoCode() functions are evaluated upfront, even though the condition order.promoApplied === false means they won’t be needed in this case.


This is a real-world problem, where eager evaluation can lead to unnecessary calculations (such as applying complex discount logic) when the function logic doesn’t actually require it.


When Is Eager Evaluation Useful?

Eager evaluation is beneficial when:

  • All arguments are cheap to compute: If the arguments involve simple values or operations, eager evaluation poses no performance issues.
  • Predictability: Since arguments are always evaluated immediately, the timing of side effects (like logging, making API calls, etc.) is predictable.


Now let's have a deeper look into lazy evalaution


Some Characteristics of Lazy Evaluation
  • Delayed Execution: Expressions or arguments are not evaluated until required by the program.
  • Efficiency: Avoids evaluating unnecessary or unused arguments, especially when dealing with expensive operations.
  • On-demand computation: The computation is deferred until the result is actually needed, leading to potential performance improvements.
  • Potential Memory Efficiency: Since evaluations happen only when necessary, lazy evaluation can reduce memory consumption.



JavaScript doesn’t support lazy evaluation natively, but we can simulate it using `higher-order functions` and `thunks` (delayed computations).

The key is to wrap an expression or computation inside a function, so it’s only evaluated when called.


Ok enough blabla, let's have some examples.



1. Delaying Expensive Computations with Thunks

A `thunk` is a function that wraps an expression to delay its execution.

Let’s see how we can use this concept in JavaScript to achieve lazy evaluation:

function expensiveOperation() {
    console.log("Expensive operation is being performed...");
    return 42;
  }
  
function lazyEval(thunk) {
return thunk(); // Thunk is evaluated only when lazyEval calls it
}

const delayedComputation = () => expensiveOperation(); // Thunk
console.log("Before evaluation");
console.log(lazyEval(delayedComputation)); // Output: Expensive operation is being performed...
  

Breakdown: Here, expensiveOperation() is not executed when delayedComputation is defined.


It only runs when we explicitly call lazyEval(delayedComputation).


This defers the execution of the expensive operation until it's actually needed, mimicking lazy evaluation.



2. Let’s enhance our earlier eager evaluation example to use lazy evaluation by deferring the expensive operations until they are needed.

const calculateDiscount = () => {
    console.log('Calculating discount...');
    return { discount: 300 };
  };
  
  const applyPromoCode = () => {
    console.log('Applying Promo...');
    return { discount: 120 };
  };
  
  function processOrder(order, calculateDiscountThunk, applyPromoCodeThunk) {    
    return {
      ...order,
      discount: calculateDiscountThunk,  // Now evaluated lazily
      promoCode: applyPromoCodeThunk,    // Now evaluated lazily
    };
  }
  
  const orderDetails = processOrder(
    { id: 1, amount: 100, promoApplied: true },
    () => calculateDiscount(),
    () => applyPromoCode()
  );
  const discount = orderDetails.promoApplied ? orderDetails.promoCode() : orderDetails.calculateDiscount();
  console.log('discount', discount);
// Output: 
// Applying Promo...
// discount { discount: 120 }

In this example, calculateDiscount() and applyPromoCode() are passed as thunks (wrapped functions).


Only one will be evaluated based on the condition order.promoApplied , allowing us to skip unnecessary computations when they aren’t needed.


3. In JavaScript, lazy evaluation can also be achieved using Promises, as they defer execution until their result is explicitly awaited.

This is sometimes useful when you need to ait for data to be available before execution proceeds.

function fetchData() {
    console.log("Fetching data...");
    return new Promise((resolve) => {
      setTimeout(() => resolve("Data fetched successfully"), 2000); // Simulate a delay
    });
  }
  
  async function getData(lazyFetch) {
    console.log("Before fetching data");
    const data = await lazyFetch();  // Fetching is deferred until this point
    console.log(data);
  }
  
  const delayedFetch = () => fetchData(); // Thunk that delays fetching
  
  getData(delayedFetch);
// Output:
// Before fetching data
// (After about 2 seconds) Fetching data...
// Data fetched successfully

Breakdown: The fetchData() function is not executed immediately. Instead, it’s wrapped in a thunk (delayedFetch) and only evaluated when getData() calls lazyFetch().


This demonstrates lazy evaluation in action when using asynchronous operations.


Although one can implement some custom thunk to simulate lazy eks to simulate lazy evalvaluation, there are some awesome, battle tested library that provide lots of utility function to help us tak advantage of functional programming concepts like, chaining, partial evaluation, currying etc...


In the next section, we will look have a brief introduction to two very popular options.


Introduction to Ramda.js and Lodash.js

In the JavaScript ecosystem, functional programming libraries like Ramda.js and Lodash.js offer utility functions that make it easier to work with immutable data, higher-order functions, and functional composition.

These libraries provide ways to implement `lazy evaluation` effectively, allowing developers to optimize performance when working with large datasets or complex operations.


Ramda.js is a functional programming library that provides a collection of utilities for working with lists, functions, and data immutability.

While Ramda does not natively support lazy evaluation in the traditional sense, it enables developers to structure code in a way that defers computation until explicitly needed, similar to lazy evaluation.

Here’s how we can use Ramda’s R.pipe() to create a composition of functions and defer execution:

const R = require('ramda');

const add = (x) => x + 2;
const multiply = (x) => x * 3;
const subtract = (x) => x - 1;

const compute = R.pipe(add, multiply, subtract);

console.log(compute(5)); // Output: (5 + 2) * 3 - 1 = 20

Breakdown: R.pipe() chains the functions, but none of them are executed until compute(5) is called, showing deferred evaluation similar to lazy evaluation.

Each function evaluates only when all its needed params are provide.


Let's see an example with lodash.

Unlike Ramda, Lodash.js explicitly supports lazy evaluation through its _.chain() function.

This allows Lodash to delay the execution of a sequence of operations until the final result is requested, which is highly useful when working with large collections.

const _ = require('lodash');

const data = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];

// Using _.chain to lazily process the array
const result = _.chain(data)
  .filter(n => n % 2 === 0)   // Lazily filter even numbers
  .map(n => n * 2)            // Lazily map (double) the values
  .take(3)                    // Take the first 3 values
  .value();                   // Finally, evaluate

console.log(result);  // Output: [4, 8, 12]

Breakdown: Lodash's lazy evaluation is activated by chaining methods like filter(), map(), and take() using _.chain(). None of these operations are executed until .value() is called, making Lodash well-suited for handling large datasets efficiently.


When to use Libraries for Lazy Evaluation:
  • When working with `large data sets` or expensive operations.
  • When `performance optimization` is needed by deferring unnecessary computations until needed.
  • Libraries like Lodash.js offer clear advantages for optimizing array processing.


Conclusion

This short beginner guide only covers the tip of the iceberg when it comes to code optimization via evaluation styles, which are concepts that require a deep understanding of the functional programming paradigm. While eager and lazy evaluation are powerful techniques, mastering them also involves understanding related concepts like :

  1. 1.Higher-Order Functions: Functions that take other functions as arguments or return functions as results are central to functional programming and evaluation strategies.
  2. 2.Currying and Partial Application: These techniques allow for creating more flexible and reusable functions, helping you defer execution and control when and how functions are applied.
  3. 3.Memoization: This is a performance optimization technique where results of expensive functions are cached, reducing the need for repeated evaluations.
  4. 4.Recursion and Tail Call Optimization: Recursive functions can lead to elegant solutions, especially in lazy evaluation contexts.
    Tail call optimization is crucial for avoiding stack overflow issues in recursive calls.
  5. 5.Pure Functions and Immutability: Understanding how pure functions work (i.e., functions without side effects) and how immutability ties into lazy evaluation helps in writing efficient, predictable code.


To sum it up, use eager evaluation when your operations are simple and you want immediate feedback or results.

This is useful in cases where efficiency isn’t a priority, or when you need to evaluate values upfront (e.g., user input validation).


But then, you cano pt for lazy evaluation when working with large datasets or computationally expensive operations, especially if not all values need to be processed.


It’s ideal for scenarios where performance optimization is key, such as processing paginated data, chaining operations on large collections, or handling asynchronous data fetches.


Choosing between eager and lazy evaluation depends on your application's performance needs.

Libraries like Lodash.js and Ramda.js provide tools that help implement these strategies effectively, empowering you to write optimized, readable, and maintainable code.


It's a wrap! If you enjoyed this piece, stay tuned for there's more 🤓.

Until then, happy cding👨‍💻!

Interested in exploring more 🤓? Check out these Related Posts.