Skip to main content

What is memoization?

Memoization is a performance optimization where a function caches its return value for a given set of inputs, so the next call with the same inputs skips the computation and returns the cached result directly.

Theory

TL;DR

  • Think of a chef writing cook times on a notepad: same order comes in, grab the note instead of cooking from scratch
  • Works only on pure functions; if a function reads Date.now() or a global counter, the cache returns stale results
  • Use it when the same function runs repeatedly with identical args and each call costs more than ~1ms
  • In React, useMemo and useCallback are the built-in tools for this pattern

Quick example

javascript
// Without memoization: fib(40) triggers ~2 billion recursive calls function fib(n) { if (n < 2) return n; return fib(n - 1) + fib(n - 2); } // With memoization: each n is computed once, rest from cache const memoize = (fn) => { const cache = {}; return (...args) => { const key = JSON.stringify(args); return cache[key] ?? (cache[key] = fn(...args)); }; }; const fastFib = memoize(fib); fastFib(40); // Computes once fastFib(40); // Returns from cache, no recomputation

The cache is a plain object keyed by serialized arguments. On a cache hit, the function body never runs.

Pure functions only

Memoization works because the same input always produces the same output. That assumption breaks the moment a function reads external state. Math.random(), Date.now(), a database query - any of these make the cached result stale immediately. The cache stores the first answer forever, even as everything around it changes.

In practice, the sneakiest version of this bug is a function that reads a module-level config variable. It looks pure because the arguments never vary, but the output depends on something external.

In React, this surfaces with object props. Pass a new object reference on every render and useMemo recomputes every time, even if the data inside is identical. React compares the deps array with shallow Object.is, so {a: 1} and {a: 1} are different objects unless they are the same reference.

When to use

  • Recursive algorithms with overlapping calls like Fibonacci: fib(40) drops from O(2^n) to O(n)
  • React components that filter or transform large lists on every render
  • Functions called repeatedly with the same IDs, such as per-user config lookups
  • Skip it for one-time calls, functions with side effects, or operations under ~1ms

How it works internally

The basic implementation is a closure over a plain object or Map. On each call, arguments are stringified into a cache key and checked for a hit. React's useMemo stores the cached value in the fiber node and on every render compares the deps array with Object.is. If deps match, it skips the computation and returns the stored value without running the function body.

Common mistakes

Memoizing an impure function

javascript
const getRandom = memoize(() => Math.random()); getRandom(); // 0.42 getRandom(); // Still 0.42 — frozen at the first call

No inputs means no key differentiation. The first result is cached and never expires.

Stale deps in React

javascript
// BUG: empty deps closes over the initial value of items const result = useMemo(() => heavyCalc(items), []); // Fix: list actual dependencies const result = useMemo(() => heavyCalc(items), [items]);

This is the most common interview mistake. The exhaustive-deps ESLint rule catches it automatically.

Over-memoizing cheap operations

javascript
// Not worth it: V8 optimizes this loop already const doubled = useMemo(() => arr.map(x => x * 2), [arr]);

Memoization adds its own overhead: key generation, cache lookup, memory allocation. For operations under ~1ms, the overhead often exceeds the savings. Profile before you memoize.

Mutating a cached object

javascript
function getData(id) { if (!cache[id]) cache[id] = fetchSync(id); cache[id].tags.push('new-tag'); // Corrupts every caller sharing this cache entry }

The cache holds a reference, not a copy. Any code pointing at that cached object sees the mutation.

Real-world usage

  • React 18: useMemo for filtered lists, useCallback to stabilize event handler references passed to children
  • Lodash: _.memoize for utility functions shared across many components
  • Redux Toolkit: createSelector via Reselect for derived state like todos filtered by userId
  • Next.js 14: unstable_cache for server components fetching the same data across requests
  • Node.js/Express: middleware caching JSON responses for read-heavy endpoints like /api/users/:id

Follow-up questions

Q: What is the difference between memoization and caching?
A: Memoization is function-scoped and lives in memory for the lifetime of the closure or program. General caching is app-wide, often stored in Redis or another external store, with explicit expiry policies.

Q: When does useMemo not prevent a re-render?
A: When the parent passes a new object reference as a prop on every render, even if the data inside is the same. The deps comparison is shallow, so two separate {a: 1} objects are not equal. You need useCallback upstream to stabilize the reference first.

Q: What is the space tradeoff with memoization?
A: The cache grows with every unique input combination. For functions with many distinct inputs, memory usage can become significant. A common fix is TTL-based eviction: store {value, expiry} in the cache and recompute when Date.now() exceeds the expiry.

Q: Why memoize fib if V8 already optimizes JavaScript execution?
A: JavaScript has no tail call optimization in practice. Without memoization, fib(40) makes roughly 2^40 recursive calls. With memoization, each value from 0 to 40 is computed exactly once.

Examples

Basic: memoize wrapper

javascript
function memoize(fn) { const cache = new Map(); return function(...args) { const key = JSON.stringify(args); if (cache.has(key)) return cache.get(key); const result = fn(...args); cache.set(key, result); return result; }; } function slowSquare(n) { // Imagine this computation takes 50ms return n * n; } const fastSquare = memoize(slowSquare); fastSquare(4); // 16, computed fastSquare(4); // 16, from cache fastSquare(9); // 81, computed (new input)

The wrapper intercepts every call, checks the cache first, and only runs the original function on a miss. A Map handles key lookups more reliably than a plain object for non-string inputs.

Intermediate: React todo list with useMemo

A list of 1000 todos should not re-filter on every keystroke in an unrelated search input.

javascript
import { useMemo, useState } from 'react'; function TodoList({ todos }) { const [filter, setFilter] = useState('all'); const [searchText, setSearchText] = useState(''); // Without useMemo: filters 1000 items on every render, including searchText changes const visibleTodos = useMemo(() => todos.filter(todo => { if (filter === 'all') return true; return filter === 'done' ? todo.done : !todo.done; }), [todos, filter] // Recomputes only when todos or filter changes, not searchText ); return ( <> <input value={searchText} onChange={e => setSearchText(e.target.value)} placeholder="Search..." /> <ul>{visibleTodos.map(todo => <li key={todo.id}>{todo.text}</li>)}</ul> </> ); }

Typing in the search input triggers re-renders, but visibleTodos reads from cache every time. The filter only re-runs when todos or filter actually changes.

Short Answer

Interview ready
Premium

A concise answer to help you respond confidently on this topic during an interview.

Finished reading?