Memoization in JavaScript: Optimising Function Calls

Image by Fredy Jacob.

In Brief

Memoisation is a way to cache the result of a function so that repeated calls with the same inputs can return without doing the same work again. It is most useful for expensive calculations, recursive functions, repeated transformations, and selected React render paths using useMemo, useCallback, or memo(). It should still be used deliberately, because every cache adds memory use and complexity.

When writing JavaScript, we sometimes encounter situations where functions are called repeatedly with the same inputs, potentially performing expensive calculations each time. This repetition wastes resources, slows down our applications, and makes our code less efficient.

Memoization is a straightforward technique that solves this problem by caching results. In simple terms, it 'remembers' the outcome of previous function calls, so future calls with the same inputs can return instantly without recalculating anything.

In this article, I intend to explain clearly what memoisation is, show you how it works, and demonstrate how you can use it practically to improve selected JavaScript functions. Memoisation is the British spelling; memoization is the American spelling used in the article title and in much API documentation.


What Exactly is Memoization?

Memoization is simply a method of caching the output of functions. If you call a memoized function with the same arguments again, it quickly retrieves the stored result rather than recalculating it from scratch.

It's particularly useful when working with computationally expensive or recursive functions, as it reduces unnecessary repeated work.


A Practical Example of Memoization

Imagine we have a function that calculates Fibonacci numbers, a classic example that's very slow without optimisation. Here's a simple, unoptimised Fibonacci function:

const fibonacci = (n: number): number => {  if (n <= 1) return n;  return fibonacci(n - 1) + fibonacci(n - 2);};

If you call fibonacci(40), you'll quickly notice that it is extremely slow. Why? Because it repeats the same calculations repeatedly.

We can optimise this using memoization.

Optimising with Memoization

Here's the same Fibonacci function, now optimised clearly with memoization:

const fibonacciMemoized = () => {  const cache: Record<number, number> = {};  const fib = (n: number): number => {    if (n <= 1) return n;        if (cache[n]) return cache[n];    cache[n] = fib(n - 1) + fib(n - 2);    return cache[n];  };  return fib;};const fibonacci = fibonacciMemoized();console.log(fibonacci(40));  // Much faster now

How Memoization Works Clearly Explained

Memoization essentially does two simple things:

  • Checks if the result for given inputs is already stored in the cache.
  • If it's there, it returns the cached value immediately.
  • If it's not cached, it calculates, stores, and returns the new result.

This approach avoids repeating an expensive computation for a key whilst that cache entry remains valid. It only improves performance when the saved work outweighs cache lookup, storage and maintenance costs.


When Does Memoization Actually Help?

Memoization isn't always necessary, and can actually introduce overheads that slow your application down if applied to too many functions that simply don't need it. However, it really shines when:

  • Your function takes noticeable time or resources to calculate.
  • The function often runs with the same input arguments.
  • Your inputs are straightforward (e.g., numbers, strings), making them easy to cache.

Practical uses include:

  • Recursive algorithms (like our Fibonacci example).
  • Heavy data transformations or mathsheavy calculations.
  • Responses from APIs or database queries only when the cache has an explicit expiry or invalidation policy.

If your function already runs quickly, adding memoization will probably not help much and might even complicate things unnecessarily.


Creating a Simple Memoization Utility

To make memoisation convenient for synchronous functions whose arguments are primitive values, we can create a reusable function like this:

type MemoizableArgument =  | string  | number  | boolean  | bigint  | symbol  | null  | undefined;const NO_VALUE = Symbol('no value');type CacheNode<Result> = {  children: Map<MemoizableArgument, CacheNode<Result>>;  value: Result | typeof NO_VALUE;};const createCacheNode = <Result>(): CacheNode<Result> => ({  children: new Map(),  value: NO_VALUE,});const memoize = <  Args extends readonly MemoizableArgument[],  Result,>(  fn: (...args: Args) => Result,): ((...args: Args) => Result) => {  const root = createCacheNode<Result>();  return (...args: Args): Result => {    let node = root;    for (const argument of args) {      let child = node.children.get(argument);      if (!child) {        child = createCacheNode<Result>();        node.children.set(argument, child);      }      node = child;    }    if (node.value !== NO_VALUE) {      return node.value;    }    const result = fn(...args);    node.value = result;    return result;  };};// Example useconst slowMultiply = (a: number, b: number) => {  console.log('Calculating...');  return a * b;};const fastMultiply = memoize(slowMultiply);console.log(fastMultiply(2, 3));  // 'Calculating...' and then 6console.log(fastMultiply(2, 3));  // returns 6 from the cache

The nested maps preserve the type and identity of each primitive argument, so values such as null and undefined cannot collide. The sentinel distinguishes a cached undefined result from a missing result. Object arguments are deliberately outside this utility's contract because their identity, mutation and structural equality need an applicationspecific policy.

The utility has no expiry or size limit, and it stores only successful return values: an exception leaves no cache entry. Use it only where results remain valid and repeated calls are safe to skip. Functions with side effects, timedependent results, external state or unbounded input combinations need a different cache policy or should not be memoised.


Memoization in React

If you're building applications with React, profiling may reveal expensive recalculations or memoised components that receive unstable props. React provides useMemo and useCallback for these measured cases.

Using useMemo

useMemo lets us cache the results of expensive calculations within components. Here's an example:

import { useMemo } from 'react';const MyComponent = ({ data }: { data: number[] }) => {  const expensiveCalculation = useMemo(() => {    console.log('Calculating...');    return data.reduce((sum, value) => sum + value, 0);  }, [data]);  return <div>Total: {expensiveCalculation}</div>;};

With expensiveCalculation wrapped in useMemo, the calculation will only run a second time when the data prop changes. Otherwise, React instantly returns the cached result instead.

Using useCallback

useCallback preserves a function's identity whilst its dependencies are unchanged. This is useful when another boundary observes that identity, such as a memoised child receiving the function as a prop:

import { memo, useCallback } from 'react';const ButtonComponent = memo(({ onClick }: { onClick: () => void }) => (  <button onClick={onClick}>Click me</button>));const ParentComponent = () => {  const handleClick = useCallback(() => {    console.log('Button clicked!');  }, []);  // identity remains stable whilst dependencies are unchanged  return <ButtonComponent onClick={handleClick} />;};

With useCallback, React understands that the handleClick function keeps the same identity whilst its dependency list is unchanged. Because ButtonComponent is wrapped in memo(), that stable onClick prop lets it skip a render when the parent rerenders without changing any child prop. useCallback alone would not prevent a nonmemoised child from rendering.

Using React's memo()

Taking things one step further still, another tool that React provides for memoization is the memo() function, which lets you memoize entire components. Wrapping a component in memo() lets React skip rendering that component when every prop compares equal with its previous value.

For example:

import { memo } from 'react';type MyComponentProps = {  value: number;};const MyComponent = ({ value }: MyComponentProps) => {  console.log('Rendering MyComponent');  return <div>Value is {value}</div>;};export default memo(MyComponent);

With this, React only rerenders MyComponent when its value prop changes. It may still render for its own state or context changes, and React may render it for other implementation reasons. This approach is most useful when profiling shows that the skipped render is meaningfully expensive.

The wrapper has its own comparison cost, so verify the benefit with React DevTools Profiler rather than assuming an improvement.

When to Use These Hooks

You won't always need these hooks. But if you notice:

  • Slow or jerky UI when components rerender repeatedly.
  • Heavy calculations running every render.
  • Child components frequently rerendering unnecessarily.

Profile the affected interaction first, apply the narrowest appropriate tool, then measure again to confirm that render count or duration improves.


Does Memoization Improve Performance Significantly?

Memoization can give you a big speed boost, but there's always a tradeoff:

  • Speed improvement:

    Repeated calls can avoid the original calculation, although lookup and storage still have a cost.
  • Memory usage:

    Entries retain keys and results until they are evicted or the cache itself becomes unreachable.

The tradeoff depends on entry size, input variety, hit rate and the cost of the saved work. Set size and freshness boundaries for longlived caches, and measure rather than assuming that retained results are worthwhile.


Wrapping Up

Memoisation is useful when a measurable, repeatable calculation costs more than its cache. Define how inputs are compared, how long results remain valid and whether calls are safe to skip; then measure the result.

Key Takeaways

  • Memoization caches function results to prevent redundant calculations.
  • It suits expensive, repeatable functions with a defined input and freshness policy.
  • Caches retain memory and add lookup, invalidation and maintenance costs.
  • A reusable utility is safe only for the input and lifetime contract it explicitly supports.

Used selectively and verified with measurements, memoisation can remove repeated work without hiding stale results or uncontrolled memory growth.


Looking for technical direction?

I support teams that need senior judgement on React, Next.js, headless CMS architecture, performance, migrations, and technical SEO.