Blog post Technical, Frontend, JavaScript

Memoization in JavaScript

A technique used to speed up the execution of programs and often dramatically improve performance, resulting in improved user experiences.

In this article, I hope to explain not only the importance of memoization but also when it should or should not be used as a technique to improve the performance of your application. There are a lot of articles already written about memoization, so here we will specifically focus on how memoization can be implemented in JavaScript as well as when using React.

So, let's get started...

What is memoization?

Memoization is a performance optimization technique based on caching but is focused more narrowly on specific functions -- storing the results of functions and returning the cached result when the function is called with the same input. This can significantly improve the performance of a program when the function is called very often with the same arguments.

The idea behind memoization is simple: just as in mathematics, if the function is simply a transformation of the input arguments, then the result of calls to that function with a given argument will always be the same and, therefore, can be stored in a cache and returned immediately the next time it is called with the same parameter(s). However, it is a good practice to ensure that the function does not have any side-effects such as mutating the input arguments and that the results returned cannot be mutated (e.g., immutable or passed by value); if consuming code is able to mutate the value (e.g., by reference), then the cached values can be corrupted.

With this technique, we can reduce the number of times that the function performs the transformation work and improve the program's overall performance. This is why memoization is often used in programming languages and can also be valuable in JavaScript.

Let's look at an example of a memoization implementation:

function sqrt(arg) {
    if (!sqrt.cache) {
        sqrt.cache = {};
    }
    if (!sqrt.cache[arg]) {
        return sqrt.cache[arg] = Math.sqrt(arg);
    }

    return sqrt.cache[arg];
}

console.log(sqrt(4)); // Return 2 by executing the Math.sqrt() method
console.log(sqrt(4)); // Return 2 from cache
console.log(sqrt(9)); // Return 3 by executing the Math.sqrt() method
console.log(sqrt(9)); // Return 3 from cache

This code example shows a function that calculates the square root of a number but also implements memoization. The function checks if there is a previously computed and cached value for the specified number, and if so, the function can immediately return that value. If the function does not contain the square root result of the number specified, then the calculation must be performed so that the result can be stored in the cache for future use.

Of course, this is a trivial example without any real performance issues being resolved by memoization, but that is only because the complexity of the calculation is quite low. However, we would have measurable performance improvements using memoization if we had a much more complex or computationally intensive calculation, such as computing hash values or the Fibonacci sequence.

Memoization in React

The fact that popular frameworks and libraries have it built in, such as the React library, shows how important this technique really is for real-world applications. While we can use memoization in the same way as we would with pure JavaScript (example above), this technique is actually embedded inside React with the useMemo hook.

The useMemo hook provides a very elegant wrapper for any function that we want to memoize. Rather than having the caching logic inside our function (where it may be duplicated), it encapsulates the caching logic and wraps all calls to our function, treating the memoization of our function as a cross-cutting concern.

The basic principle of React applications is their ability to automatically re-render UI components upon the changing of input parameters. This reaction to input changes is the real flexibility and power that this library offers. However, sometimes, the re-rendering can be an expensive process in terms of program performance.

Let's look at another example:

import { useState } from 'react'

export default function App() {
    const [count, setCount] = useState(0);
    const [messages, setMessages] = useState([]);

    const expensiveFunction = (num) => {
        for (let i = 0; i < 1000000000; i++) {
            num += 1;
        }
        return num;
    };

    const addMessage = () => setMessages((prev) => [...prev, "Hello World!"]);
    const expensiveCalculation = expensiveFunction(count);
    
    return (
        <div>
            <p>Expensive function result: {expensiveCalculation}</p>
            { messages.map((message, index) =>
                <p key={index}>{message}</p>
            )}
            <button onClick={() => setCount(count + 1)}>
                Increment expensive function
            </button>
            <button onClick={addMessage}>
                Print 'Hello World' message
            </button>
        </div>
    );
}

In this example, our React component contains 2 buttons. Clicking on the first one executes a complex function expensiveFunction() that returns a large number increased by one as a return value. Clicking the second button prints the 'Hello World' message on the screen, but what is noticeable is that due to the execution of the complex function, a delay is felt even when printing the message. This is because React does not remember the value of the executed function and, therefore, must re-execute it when clicking on the second button because it is a required dependency to re-render the component for any change.

The problem here is resolved by memoization (aka caching), and the implementation of this technique in the React library is done using the useMemo hook.

Below is a modification to our React example so that the message is printed on the second button click without any delay, providing a much better user experience.

// Import useMemo hook
import { useState, useMemo } from 'react';

// Pass expensiveFunction() to useMemo hook
const expensiveCalculation = useMemo(() => expensiveFunction(count), [count]); 

To learn more about how the useMemo() hook works, visit the latest React documentation.

Why isn't memoization the silver bullet solution we should always use?

One potential drawback of memoization is that it can use a lot of memory if the function is called with a large number of different input values or the results are large (e.g., large HTML Page results). This is because the memoized function stores the results of all of these function calls in memory – usually forever. This means that the storage utilization can quickly increase if the function is called frequently with many different input values and could even be considered a memory leak if the inputs are rarely (or never) the same.

In addition, memoization may not be appropriate if the function being memoized has very low overhead, such that the cost of storing and looking up the results in the cache is similar to (or greater than) the cost of simply re-executing the function's original work.

Finally, a small amount of complexis is added to the code when implementing the memoization caching technique, which may affect long-term maintenance. Granted, this is significantly reduced by the elegant encapsulation provided via the useMemo hook.

Overall, while memoization can be a fantastic optimization in many use cases, it is important to carefully consider whether it is appropriate for a particular problem and to weigh the trade-offs in terms of memory usage, complexity, code maintenance, and performance.

Contact us to discuss your project.
We're ready to work with you.
Let's talk