Memoisation in JavaScript: Optimising Function Calls

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.
Memoisation caches results so that another call with equivalent inputs can reuse the earlier result. It is useful when that saves more work than maintaining and looking up the cache.
Here, I'll explain how memoisation works and show where it can help selected JavaScript functions. The examples also need a clear contract for comparing inputs and deciding how long a result remains valid.
What Exactly is Memoisation?
Memoisation is simply a method of caching the output of functions. If you call a memoised 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 Memoisation
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 memoisation.
Optimising with Memoisation
Here's the same Fibonacci function, now optimised clearly with memoisation:
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 nowHow Memoisation Works Clearly Explained
Memoisation 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 Memoisation Actually Help?
Memoisation 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 maths‑heavy 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 memoisation will probably not help much and might even complicate things unnecessarily.
Creating a Simple Memoisation Utility
For synchronous functions with primitive arguments, the following helper uses Map keys to decide which calls can share a result. Use it only when arguments that compare equal under SameValueZero are interchangeable for the function:
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 use
const slowMultiply = (a: number, b: number) => {
console.log('Calculating...');
return a * b;
};
const fastMultiply = memoize(slowMultiply);
console.log(fastMultiply(2, 3)); // 'Calculating...' and then 6
console.log(fastMultiply(2, 3)); // returns 6 from the cacheThe nested maps use SameValueZero equality: NaN matches NaN, and 0 shares a key with -0. That makes this helper unsuitable for calculations that distinguish signed zero, such as 1 / x. Other primitive types remain distinct, including null and undefined. The sentinel distinguishes a cached undefined result from a missing result; object arguments remain outside this helper's contract.
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, time‑dependent results, external state or unbounded input combinations need a different cache policy or should not be memoised.
Memoisation 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>;
};React can reuse the useMemo result when each dependency compares equal using Object.is. Here that means the same data array reference. This is a performance optimisation, not a semantic guarantee: React may discard the cache, so the calculation must remain pure and correct when it runs again. Replace the array when its contents change rather than mutating it in place.
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 re‑renders without changing any child prop. useCallback alone would not prevent a non‑memoised child from rendering.
Using React's memo()
Taking things one step further still, another tool that React provides for memoisation is the memo() function, which lets you memoise 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 re‑renders 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 re‑render repeatedly.
- Heavy calculations running every render.
- Child components frequently re‑rendering unnecessarily.
Profile the affected interaction first, apply the narrowest appropriate tool, then measure again to confirm that render count or duration improves.
Does Memoisation Improve Performance Significantly?
Memoisation can give you a big speed boost, but there's always a trade‑off:
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 trade‑off depends on entry size, input variety, hit rate and the cost of the saved work. Set size and freshness boundaries for long‑lived 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
- Memoisation 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.