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
// 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.
// 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}`; // 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.
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.
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
amortizecall. - 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.
useMemoand$derivedkeep 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.
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.
Takes four numbers
Pure, so remembering its answer is safe.
Remembers the rows
Until one of the numbers it reads changes.
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.
// 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.
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.
| Where you’ve seen it | What it remembers | Same means |
|---|---|---|
React useMemo | The last result | Each dependency passes Object.is |
Svelte $derived | The current value | Nothing it read has changed |
A JavaScript Map with object keys | One result per key | The same object |
| A Go map with a struct key | One result per key | Every field is == |
Go’s sync.Map | One result per key, concurrently | Keys 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.
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.
| The change | Recalculate | Memoize |
|---|---|---|
| One calculation per button press | Fine. | A cache for nothing. |
| A comparison table on every slider move | Repeats the same work. | Lookups for rates it’s seen. |
| Add a property-tax input | Nothing else to update. | The key must include it. |
| A page left open all day | Nothing retained. | Bound the cache. |
| Debug a wrong payment | Call 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
- Pure functions and side effects explains why only pure functions are safe to memoize.
- Copying, identity, and equality covers the difference between the same object and an equal one.
- LRU cache builds the eviction the bounded memo uses.
- Lazy initialization computes one thing once; memoization computes each distinct input once.