Definition
Lazy initialization delays creating a value, or doing the work behind it, until the first time a caller needs it, then keeps the result so later callers reuse it.
01Recognize the up-front cost
An intake lookup earns its setup.
The simple option is to build the table as each center opens. That makes every later lookup ready and reports setup trouble at a predictable time. It also makes every opening pay, whether intake uses the table or not.
// Eager: the center builds its lookup as the desk opens, before anyone asks.
class CareSheets {
private index: Map<string, number[]>;
constructor(sheets: Sheet[]) {
this.index = buildIndex(sheets); // throws if the export is still syncing
}
lookup(word: string): number[] {
return this.index.get(word) ?? [];
}
} Sheet and the recipe, buildIndex, are the ones in the program below.
Building in the constructor is clear and often right: one place does the setup, and by the
time anyone holds a center, its lookup works.
02Keep the recipe with its owner
Keep the initializer with its owner.
Then the desk starts opening at six, before the night’s care-sheet export has finished syncing, and most shifts at a small center look nothing up. The eager desk cannot open until the export is done, and every opening builds a table nobody may read.
Lazy initialization keeps the recipe and waits for the first lookup: that call builds the center’s species-to-care-sheet table, and later calls reuse it. The desk still owns when to request a sheet, and the center instance owns the retained result.
The slot is created when a center is created, outside any one lookup. Recreating it for each intake would make every slot initialize once while the center as a whole rebuilds repeatedly. This is the whole program the lab runs: the export, the recipe, the owner and one shift at the desk.
type Sheet = { id: number; species: string };
// The night's care-sheet export: still syncing when the desk opens.
let exportReady = false;
// The recipe: each word of a species name → the ids of its sheets.
function buildIndex(sheets: Sheet[]): Map<string, number[]> {
if (!exportReady) throw new Error('export still syncing');
const index = new Map<string, number[]>();
for (const sheet of sheets) {
for (const word of sheet.species.toLowerCase().split(' ')) {
index.set(word, [...(index.get(word) ?? []), sheet.id]);
}
}
return index;
}
// One center's care sheets. The lookup waits for the first lookup.
class CareSheets {
builds = 0;
private sheets: Sheet[];
private index: Map<string, number[]> | undefined;
constructor(sheets: Sheet[]) {
this.sheets = sheets;
}
browse(): Sheet[] {
return this.sheets;
}
lookup(word: string): number[] {
this.index ??= this.build();
return this.index.get(word) ?? [];
}
private build(): Map<string, number[]> {
this.builds++;
return buildIndex(this.sheets);
}
}
const center = new CareSheets([
{ id: 1, species: 'Red fox' },
{ id: 2, species: 'River otter' },
{ id: 3, species: 'Barn owl' },
{ id: 4, species: 'Hedgehog' },
]);
function attempt(word: string): string {
try {
return `${word}: [${center.lookup(word).join(', ')}]`;
} catch (error) {
return `${word}: ${(error as Error).message}`;
}
}
console.log(`opened: builds=${center.builds}`);
const browsed = center.browse();
console.log(`browse: ${browsed.length} sheets, builds=${center.builds}`);
const first = attempt('fox');
console.log(`${first}, builds=${center.builds}`);
exportReady = true; // the export finishes syncing
const second = attempt('fox');
console.log(`${second}, builds=${center.builds}`);
const third = attempt('owl');
console.log(`${third}, builds=${center.builds}`); | Role | Responsibility | What it handles |
|---|---|---|
| Intake desk | Decides when to ask | Browses, and looks a species up when an animal arrives. |
| CareSheets | Owns the recipe and the slot | Builds on the first lookup, keeps a success, counts builds. |
| buildIndex | Knows how to build | Turns the sheets into word → sheet ids; fails while the export syncs. |
What a production lookup addsNormalized lookups, copies, and paying early
The same owner, checked. A lookup is trimmed and folded to lower case (ASCII only, the same
rule in every language), and a blank one returns nothing without building: it is not a
demand. The center keeps its own copy of the sheets and hands out copies of its results, so
no caller can change the table under the others. The export’s readiness is passed in, so
tests can make it fail. And warm builds on purpose, for a desk that would rather
pay before the first animal arrives.
export type Sheet = Readonly<{ id: number; species: string }>;
// Trim ASCII whitespace and fold ASCII A–Z: the same rule in every language.
export function normalize(query: string): string {
return query
.replace(/^[\t\n\v\f\r ]+|[\t\n\v\f\r ]+$/g, '')
.replace(/[A-Z]/g, (letter) => letter.toLowerCase());
}
// The recipe: each word of a species name → the ids of its sheets.
export function buildIndex(sheets: readonly Sheet[]): Map<string, number[]> {
const index = new Map<string, number[]>();
for (const sheet of sheets) {
const words = new Set(normalize(sheet.species).split(/[^a-z]+/).filter(Boolean));
for (const word of words) index.set(word, [...(index.get(word) ?? []), sheet.id]);
}
return index;
}
export class CareSheets {
#sheets: Sheet[];
#exportReady: () => boolean;
#index: Map<string, number[]> | undefined;
#builds = 0;
constructor(sheets: readonly Sheet[], exportReady: () => boolean = () => true) {
this.#sheets = sheets.map((sheet) => ({ ...sheet })); // this center's own copy
this.#exportReady = exportReady;
}
get builds(): number {
return this.#builds;
}
browse(): Sheet[] {
return this.#sheets.map((sheet) => ({ ...sheet }));
}
// A blank lookup is not a demand: it returns nothing and builds nothing.
lookup(query: string): number[] {
const word = normalize(query);
if (!word) return [];
return [...(this.#getIndex().get(word) ?? [])];
}
// Build now, for a caller that would rather pay before the first lookup.
warm(): number {
return this.#getIndex().size;
}
// A success is kept. A failed build keeps nothing: the next demand tries again.
#getIndex(): Map<string, number[]> {
this.#index ??= this.#build();
return this.#index;
}
#build(): Map<string, number[]> {
this.#builds++;
if (!this.#exportReady()) throw new Error('export still syncing');
return buildIndex(this.#sheets);
}
} JavaScript runs one lookup at a time, so a synchronous build cannot race. An asynchronous one can: see the next section.
03Predict the first failure
What does a first failure leave behind?
Experiment
One center, one lookup, built on demand
// Line 33 becomes a build that never throws:
this.index ??= this.tryBuild();
// and after build() (line 40), a failed build returns an empty index:
private tryBuild(): Map<string, number[]> {
try {
return this.build();
} catch {
return new Map();
}
} Reading the TypeScript??=, a slot’s empty value, private
a ??= b evaluates b and assigns it only while a is null or undefined. If b throws, nothing is assigned:
the slot stays empty, and that is the whole retry policy.
A slot needs one value that means “not built” and can never be a result. Here it is undefined, because a built index is always a Map, even an empty
one. A slot for a count or a flag would need more care: ||= treats 0 and false as empty and rebuilds them every time.
private is checked by the compiler only. The production version uses #index, which JavaScript itself keeps private.
04Meet it in your language
Your language has a run-once tool. Check what it keeps.
In TypeScript you meet a lazy slot whenever something loads on first use: data, a client, a
dynamic import(). For asynchronous setup, keep the pending promise at once, so
callers that arrive together share one attempt. A promise coordinates who receives the
result; it does not move work to another thread. Then decide what a failure leaves in the
slot.
type Sheet = { id: number; species: string };
async function download(): Promise<Sheet[]> {
const response = await fetch('/care-sheets.json');
if (!response.ok) throw new Error(`care sheets: HTTP ${response.status}`);
return (await response.json()) as Sheet[];
}
// Before: the first call starts the download and every caller shares it,
// even several that arrive at once. The slot keeps the promise it was given.
let pendingBefore: Promise<Sheet[]> | undefined;
export function loadSheetsBefore(): Promise<Sheet[]> {
pendingBefore ??= download();
return pendingBefore;
}
// After: callers still share one download, but a failed one empties the
// slot, so the next call starts again.
let pending: Promise<Sheet[]> | undefined;
export function loadSheets(): Promise<Sheet[]> {
pending ??= download().catch((error: unknown) => {
pending = undefined;
throw error;
});
return pending;
} loadSheetsBefore keeps a rejected promise: one download that failed while the
desk was offline, and every later call rejects until the page reloads. Emptying the slot in catch keeps the sharing and lets the next call try again. React.lazy keeps a failed import() the same way.
In the frontend React’s initializer function and key Optional
In React the component is the owner and useState is the slot: React keeps the
first render’s state and ignores the initial value after that. The argument is still computed
on every render, though, so useState(buildIndex(sheets)) builds the species index
on every keystroke in the search box and throws it away. Pass the recipe instead, and React
calls it once.
import { useState } from 'react';
import { buildIndex, normalize, type Sheet } from '../sheets';
// Before: React runs this function on every render, so buildIndex(sheets)
// runs on every keystroke in the box. React keeps the first render's index
// and throws each later one away.
export function SpeciesSearchBefore({ sheets }: { sheets: Sheet[] }) {
const [index] = useState(buildIndex(sheets));
const [word, setWord] = useState('');
const ids = index.get(normalize(word)) ?? [];
return (
<label>
Species <input value={word} onChange={(event) => setWord(event.target.value)} />
<output>{ids.join(', ') || 'no sheet'}</output>
</label>
);
}
// After: pass the recipe, not its result. React calls it on the first render
// only, and keeps the index for as long as this component lives.
export function SpeciesSearch({ sheets }: { sheets: Sheet[] }) {
const [index] = useState(() => buildIndex(sheets));
const [word, setWord] = useState('');
const ids = index.get(normalize(word)) ?? [];
return (
<label>
Species <input value={word} onChange={(event) => setWord(event.target.value)} />
<output>{ids.join(', ') || 'no sheet'}</output>
</label>
);
}
// The component is the owner. Another center needs another owner: with a key,
// React starts a new SpeciesSearch, and builds that center's index once.
export function IntakeDesk({ center }: { center: { id: string; sheets: Sheet[] } }) {
return <SpeciesSearch key={center.id} sheets={center.sheets} />;
} The slot lives as long as the component. Hand the same SpeciesSearch another
center’s sheets and it keeps the first center’s index; a key per center makes
React start a new owner. Both versions build when the search first renders; to wait for the
first search, keep the index in a useRef and fill it in the handler the first
time it is empty.
In Svelte a component’s script runs once per instance, so const index = buildIndex(sheets) there is built once without a function form, and {#key center.id} gives each center a new instance, as React’s key does.
05Decide when it helps
When would you keep it simple?
Give the code a plausible change, and follow what each version has to do about it.
| The change | Built as the desk opens | Built on the first lookup |
|---|---|---|
| Every shift looks something up in its first minute | Simple: the cost is paid before anyone waits. | The same work, paid by the first intake instead. |
| The export may not have finished syncing | The desk cannot open: loud, early, and blocking. | The desk opens; the first lookup reports it. Decide what the next lookup does. |
| Lookups arrive on several threads or requests | Built before anyone shares it: nothing to coordinate. | First lookups race: guard the slot, or two builds run. |
| Care sheets change during the shift | Neither helps: the lookup is built once. | Neither helps: that is a cache with invalidation, another design. |
Keep eager setup when every center immediately needs the lookup, when the work is cheap, or when early failure is more useful than a faster opening. Lazy setup fits when the work is optional or costly enough to defer, and when the first caller can handle its result and possible delay.
The pattern decides when. It leaves three decisions to you: who owns the slot (this center, not one request and not the whole process), what a failure leaves in it, and who waits when first callers arrive together. Recovery is its own decision too: a new owner can retry with fresh state, or an application can define an explicit retry operation that clears the old outcome. The rule for what goes where: the recipe and the slot stay with the owner whose data they describe; the decision of when to ask stays with the caller.
Its neighbours: Singleton makes one instance for the process, often lazily, but how many and when are separate choices; Proxy can stand in for an expensive object and create it on first use; Object pool keeps expensive objects for reuse, often opened lazily; and a cache keeps results that can go stale, which a lazy value never refreshes.
06Make the call
Where does the owner live?
Before you open the answer below, say it in your own words: what does a lazy slot hold before, during and after its first build, and what should a later caller see after that build fails?
One way to say itWho owns the slot, and what it keeps
A lazy value keeps its recipe and an empty slot. The first caller that needs it runs the recipe and fills the slot; later callers read it. The caller still decides when to ask, and the owner, the center here, keeps the slot for as long as the data it describes. If a helper initializes only once but you still see repeated work, ask what owns the helper and whether that owner survives between calls. Then ask what should happen after a failed first attempt: keep the failure until a new owner starts over, which suits one that will not heal, or keep nothing so the next demand retries, which suits one that will. Check which one your run-once tool picked. And when every caller needs the value at once and it is cheap, build it eagerly: the answers define the lifetime and recovery policy; the word “lazy” alone does not.
Practice in the lab
An intake server for a network of wildlife rehabilitation centers. Each center has its own care sheets and its own species lookup, and the night’s exports do not all finish syncing before the server starts. Each level starts from the previous level’s finished code, so you can begin at any of them.
- LAZY-INIT-101 (easy) One syncing export keeps the whole server down
- LAZY-INIT-102 (medium) The enclosure plan is read again on every placement Pro
- LAZY-INIT-103 (hard) Review: a faster start that moves every failure to the first request Pro