← Concepts & practices
Concept Design principles and language mechanisms

Memoization

Remember the answer, if you can say what “same” means.

You already reach for useMemo, or let $derived do it for you. Let’s follow a mortgage calculator that works out a 360-month schedule every time a slider twitches, add a cache, and find out that “the same inputs” is the hard part.

TypeScriptGo One calculator, two implementations.

01 / The idea

Working the schedule out every time is a fair start.

You’re building a mortgage calculator. amortize(terms) takes the loan, the rate, the years, and an optional extra monthly payment, and walks the schedule month by month to the payoff date and total interest. One call is quick, and it always gives the right answer.

Read the first versionTypeScript · the version this lesson starts from
mortgage.ts
// The first version: work out the whole schedule every time the calculator asks.
export function amortize(terms: LoanTerms): Summary {
	const monthlyRate = terms.rateBps / 120_000;
	const scheduled = terms.years * 12;
	let growth = 1;
	for (let month = 0; month < scheduled; month++) growth *= 1 + monthlyRate;
	const paymentCents =
		monthlyRate === 0
			? Math.ceil(terms.principalCents / scheduled)
			: Math.round((terms.principalCents * monthlyRate * growth) / (growth - 1));

	let balance = terms.principalCents;
	let months = 0;
	let interestCents = 0;
	while (balance > 0 && months < scheduled) {
		const interest = Math.round(balance * monthlyRate);
		const paid = Math.min(balance + interest, paymentCents + terms.extraCents);
		balance = balance + interest - paid;
		interestCents += interest;
		months++;
	}
	return { paymentCents, months, interestCents };
}

Go’s Amortize takes the same steps with the same numbers. Both languages meet again at the memo in section 02.

Then the page grows a rate slider and a table comparing nine rates. Every slider move works out nine schedules, and nudging the rate back to where it was works them out again. The first cache someone adds stores results under the terms object, and the slider builds a new object on every move.

Memoization means remembering a function’s result by its inputs, so a call with the same inputs returns the stored result instead of repeating the work. It only works when you can say exactly what “the same inputs” means: every value the function reads, compared the way the cache compares its keys. Everything that goes wrong in this lesson is a disagreement about “same”.

Section 05 builds a rate comparison that recalculates only when its numbers change, in React and Svelte.

02 / See the shape

Key the cache by the values the function reads.

The basic form wraps amortize in a memo keyed by every field it reads. In the wild bounds the cache so a long session can’t fill memory. At the call site nudges and sweeps a rate slider through both.

Both languages produce the same schedules and the same cache counts.

A memo keyed by every value the calculation reads. TypeScript builds a string key; Go uses the comparable struct itself, behind a mutex.

TypeScriptReading
mortgage.ts
// Remember results by a key made from the values the function reads.
export function memoize<A extends unknown[], R>(
	fn: (...args: A) => R,
	keyOf: (...args: A) => string
) {
	const cache = new Map<string, R>();
	const stats = { hits: 0, misses: 0 };
	function memoized(...args: A): R {
		const key = keyOf(...args);
		if (cache.has(key)) {
			stats.hits++;
			return cache.get(key) as R;
		}
		stats.misses++;
		const value = fn(...args);
		cache.set(key, value);
		return value;
	}
	return Object.assign(memoized, { cache, stats });
}

// Every field amortize reads is in the key, compared as a value.
export const termsKey = (terms: LoanTerms) =>
	`${terms.principalCents}:${terms.rateBps}:${terms.years}:${terms.extraCents}`;
GoAlongside
mortgage.go
// Memo remembers results by key. LoanTerms is a struct of ints, so it's comparable: two terms
// with equal fields are the same key, however they were built.
type Memo[K comparable, V any] struct {
	mu      sync.Mutex
	compute func(K) V
	entries map[K]V
	Hits    int
	Misses  int
}

func NewMemo[K comparable, V any](compute func(K) V) *Memo[K, V] {
	return &Memo[K, V]{compute: compute, entries: map[K]V{}}
}

// Get holds the lock while computing, so two goroutines never compute the same key twice.
func (m *Memo[K, V]) Get(key K) V {
	m.mu.Lock()
	defer m.mu.Unlock()
	if value, ok := m.entries[key]; ok {
		m.Hits++
		return value
	}
	m.Misses++
	value := m.compute(key)
	m.entries[key] = value
	return value
}

func (m *Memo[K, V]) Len() int {
	m.mu.Lock()
	defer m.mu.Unlock()
	return len(m.entries)
}
Reading the TypeScriptWhy the key is a string

A Map compares string and number keys by value, but MDN notes that “for object keys, equality is based on object identity. They are compared by reference, not by value.” termsKey turns the four fields into one string so equal terms make equal keys.

The bounded memo uses a Map’s insertion order: deleting and re-adding a key marks it recent, and the first key is the one used longest ago.

Reading the GoComparable structs and a mutex

Go can key a map by the struct itself. The spec requires that “The comparison operators == and != must be fully defined for operands of the key type”, and “Struct types are comparable if all their field types are comparable.” A *LoanTerms key would compare by pointer, which is the test’s version of the object-keyed mistake.

Requests run on many goroutines, so the memo locks a plain map. The standard library’s sync.Map exists, but its docs say “Most code should use a plain Go map instead, with separate locking or coordination”.

03 / Count the work

Watch which moves do the work, and which answers are right.

Five steps. Each runs slider positions through a cache and counts real calls to amortize; every cached answer is compared with a fresh one. Before each step, guess how many schedules get worked out.

In Try it, pick a cache and drag the sliders yourself.

Memoization

Did it do the work again?

Recalculate on every change. 6.50%: computed, $2,528.27/mo · 360 months. 6.55%: computed, $2,541.44/mo · 360 months. 6.50%: computed, $2,528.27/mo · 360 months. 6.55%: computed, $2,541.44/mo · 360 months. 6.50%: computed, $2,528.27/mo · 360 months. slider moves 5, schedules calculated 5. Three of the five moves go back to a rate the calculator has already worked out, and it works it out again.

01/ 05
Nudge the rate slider five times

Same inputs, same work.

Five slider moves, five full schedules, though only two rates.

Reduced motion: choose a scene to see its completed state.

Read this scene

Five slider moves, five full schedules, though only two rates.

Recalculate on every change. 6.50%: computed, $2,528.27/mo · 360 months. 6.55%: computed, $2,541.44/mo · 360 months. 6.50%: computed, $2,528.27/mo · 360 months. 6.55%: computed, $2,541.44/mo · 360 months. 6.50%: computed, $2,528.27/mo · 360 months. slider moves 5, schedules calculated 5. Three of the five moves go back to a rate the calculator has already worked out, and it works it out again.

Watch restarts when you return. Step through keeps your selected step. Try it starts with an empty cache each time you open it.

What a well-keyed memo buys you

Now put names on what you just watched. These are the words you’ll hear in a design review, and each one points at something on this page.

Repeats cost a lookup
Five nudges, two schedules worked out, three answers from the cache.
The same answer, less work
Every cached answer equals a fresh amortize call.
A cache you can size
Bounded to eight, a 49-position sweep (41 rates up, eight back) keeps eight entries.
Counts you can check
Hits, misses, and evictions tell you whether it’s working.
A slider that stays smooth
With the keyed cache, going back to a recent rate is a lookup, not nine schedules. useMemo and $derived keep only the last result, so on their own they work it out again.

The review words are memoization, cache hit and miss, cache key, value equality versus identity (or reference equality), stale for an answer that no longer matches its inputs, and eviction for dropping entries to stay in bounds. Memoizing is safe because amortize is pure. Section 08 covers what it costs.

04 / Try a decision

A cache keyed by the terms object.

The first cache someone wrote stores results under the object it was given. The code is in by-object.ts, and the lesson’s tests pin what happens.

How many schedules does the calculator work out?

Someone adds a cache with memoizeByObject(amortize), which stores each result under the terms object it was given. The slider handler calls it with { ...home, rateBps }. A person drags the rate back and forth between 6.50% and 6.55%, fifty times.

05 / Give it a real job

A rate comparison that recalculates only when its numbers change.

In the real calculator, a table shows the monthly payment and total interest at nine rates around the one selected. It has to follow the rate and extra-payment sliders smoothly, and typing a note on the same screen shouldn’t work out a single schedule.

summarize

Takes four numbers

Pure, so remembering its answer is safe.

useMemo · $derived

Remembers the rows

Until one of the numbers it reads changes.

Rows

Take numbers too

So a row whose numbers didn’t change can be skipped.

The example leaves out taxes and insurance, saving scenarios, and deferring updates while someone drags.

Build UIs?Every useMemo dependency list is a cache key, and every $derived is a cache that decides its own key.

Where it already is in your components

useMemo is memoization with a key you write by hand. React’s reference says “React will compare each dependency with its previous value using the Object.is comparison”, which is identity for objects. The textbook calculator passes four numbers, so equal inputs really are equal.

Svelte’s $derived writes the key for you: “Anything read synchronously inside the $derived expression (or $derived.by function body) is considered a dependency”, and the value is “recalculated when it is next read.”

When you have to own it

Now it’s the comparison table. React’s reference warns that depending on an object built during render “defeats the point of memoization”, and suggests moving it “inside of the useMemo calculation function”. The rows are built inside the calculation, from four numbers, so typing a note re-renders without working out a schedule.

React also says “You should only rely on useMemo as a performance optimization. If your code doesn't work without it, find the underlying problem and fix it first.” The table is correct without it; the memo only saves work.

loan-summary.ts
// The calculator's math for the UI. It takes plain numbers, so a component can pass each one as
// its own dependency, and those compare by value.
export type Summary = { paymentCents: number; months: number; interestCents: number };

export function summarize(
	principalCents: number,
	rateBps: number,
	years: number,
	extraCents: number
): Summary {
	const monthlyRate = rateBps / 120_000;
	const scheduled = years * 12;
	let growth = 1;
	for (let month = 0; month < scheduled; month++) growth *= 1 + monthlyRate;
	const paymentCents =
		monthlyRate === 0
			? Math.ceil(principalCents / scheduled)
			: Math.round((principalCents * monthlyRate * growth) / (growth - 1));
	let balance = principalCents;
	let months = 0;
	let interestCents = 0;
	while (balance > 0 && months < scheduled) {
		const interest = Math.round(balance * monthlyRate);
		const paid = Math.min(balance + interest, paymentCents + extraCents);
		balance = balance + interest - paid;
		interestCents += interest;
		months++;
	}
	return { paymentCents, months, interestCents };
}

const dollars = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' });
export const money = (cents: number) => dollars.format(cents / 100);
export const percent = (rateBps: number) => `${(rateBps / 100).toFixed(2)}%`;

A payment calculator whose summary is remembered by four numbers: useMemo with a dependency list, or $derived tracking what it reads.

ReactAlready in your code
PaymentCalculator.tsx
import { useMemo, useState } from 'react';
import { money, percent, summarize } from './loan-summary';

export function PaymentCalculator({
	principalCents,
	years
}: {
	principalCents: number;
	years: number;
}) {
	const [rateBps, setRateBps] = useState(650);
	const [extraCents, setExtraCents] = useState(0);

	// Each dependency is a number, and React compares them with Object.is, so moving the slider
	// back to a rate recalculates, but typing in an unrelated field doesn't.
	const summary = useMemo(
		() => summarize(principalCents, rateBps, years, extraCents),
		[principalCents, rateBps, years, extraCents]
	);

	return (
		<form>
			<label>
				Rate {percent(rateBps)}
				<input
					type="range"
					min={300}
					max={900}
					step={5}
					value={rateBps}
					onChange={(event: { target: { value: string } }) =>
						setRateBps(Number(event.target.value))
					}
				/>
			</label>
			<label>
				Extra each month
				<input
					type="number"
					min={0}
					step={50}
					value={extraCents / 100}
					onChange={(event: { target: { value: string } }) =>
						setExtraCents(Number(event.target.value) * 100)
					}
				/>
			</label>
			<output>
				{money(summary.paymentCents)} a month, paid off in {summary.months} months
			</output>
		</form>
	);
}

06 / Recognize it elsewhere

Every cache has an answer to “what counts as the same?”

You’ve used all of these. For each one, say how it compares inputs.

Familiar caches, what they remember, and how they compare inputs
Where you’ve seen itWhat it remembersSame means
React useMemoThe last resultEach dependency passes Object.is
Svelte $derivedThe current valueNothing it read has changed
A JavaScript Map with object keysOne result per keyThe same object
A Go map with a struct keyOne result per keyEvery field is ==
Go’s sync.MapOne result per key, concurrentlyKeys compare as ==; built for caches that only grow

The first four remember by different rules. When a cache misses more than you expect, find its rule for “same” before you look anywhere else.

07 / Already in your toolbox

Your tools already say when to remember, and how they compare.

Three places to look. For each one, find how it decides an input hasn’t changed.

React · useMemo

Dependencies, Object.is, objects created during render, and how to tell whether a calculation is worth memoizing.

Read the reference ↗

Svelte · $derived

How dependencies are tracked, and why a derived only recalculates when it’s read.

Read the docs ↗

Go · sync.Map

When a concurrent map helps, and why most code should lock a plain map instead.

Read the docs ↗
A useful counterexample: a cheap calculationWhen not to memoize

Formatting a payment as dollars is cheaper than building a cache key for it. React’s reference: “unless you're creating or looping over thousands of objects, it's probably not expensive”, and measure first, where “1ms or more” might be worth memoizing.

08 / The parts to watch

A cache is a second copy of the truth.

These are the places it still goes wrong.

Object keys compare by identity

A new object with equal fields is a different key in a Map, a different dependency to useMemo, and, if you key a Go map by pointer, a different key there too. Key by the values.

A key that leaves out an input serves stale answers

When the extra-payment slider arrived, the key didn’t change with it. Every value the function reads belongs in the key; a type for the inputs makes that list visible.

A cache that only grows is a leak

Remembering every rate a long session visits keeps every schedule. Bound it, clear it when the screen closes, or scope it to the component. The LRU cache lesson covers eviction properly.

Memoizing an impure function hides its effects

A hit skips the whole function, including any logging, fetching, or clock reads. Cache the pure part and keep the effects outside it.

Cached results are shared

Every hit returns the same object. If one caller changes it, every later caller sees the change. Treat cached values as read-only, or return copies.

Concurrent callers need a lock

In Go, a plain map written from several goroutines is a data race. Lock it, as Memo does, and reach for sync.Map only in the cases its docs describe.

09 / Make the call

What would you have to change tomorrow?

Give both versions a plausible change and follow the work it creates.

How a change affects recalculating and memoizing
The changeRecalculateMemoize
One calculation per button pressFine.A cache for nothing.
A comparison table on every slider moveRepeats the same work.Lookups for rates it’s seen.
Add a property-tax inputNothing else to update.The key must include it.
A page left open all dayNothing retained.Bound the cache.
Debug a wrong paymentCall the function.Also suspect the cache.

Memoize a pure function when the same inputs really do repeat and the work is measurably slow, and key it by every value it reads. A slider over a table of schedules is the moment.

Recalculate when it’s cheap, when inputs rarely repeat, or when you can’t name the inputs.

The question I’d leave beside the code is: what counts as the same input here, and how will I know it repeated?

10 / Take the idea with you

Explain the cache that never hit without saying “memoization.”

“We stored each schedule under the loan object we were handed, but the page makes a new object every time the slider moves, so we never found anything and kept everything. Now we store it under the loan’s numbers, keep the last few, and a rate we’ve just shown comes back instantly.” In a review, the words are memoization, cache key, and identity versus value.

Before moving on, jot down why the object-keyed cache never hit, what the short key forgot, and one useMemo in your own code whose dependency list includes an object.

Connections to follow nextRelated lessons

Take the calculator into your editor. Fix memoizeByObject with a value key, add a property-tax field and update the key, and bound the cache to the rates a person can see.

Back to Concepts & practices →