← Design patterns
Creation Deferred setup

Lazy initialization

Keep the recipe until it is needed.

A wildlife rehabilitation intake desk can show the center’s species care sheets before anyone needs a particular one. Building the species lookup as the desk opens does work early, including for a quiet shift with no intake.

TypeScriptGoRust The same lookup in each language.

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.

sheets.ts
// 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.

sheets.ts
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}`);
Who owns what
RoleResponsibilityWhat it handles
Intake deskDecides when to askBrowses, and looks a species up when an animal arrives.
CareSheetsOwns the recipe and the slotBuilds on the first lookup, keeps a success, counts builds.
buildIndexKnows how to buildTurns 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.

sheets.ts
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

sheets.ts
// 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();
  }
}
Suppose the lookup used a build that never throws, as above: a failed build returns an empty index instead. The first fox lookup fails because the export is still syncing; then the export finishes. What does the second fox lookup return?
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.

load.ts
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.

SpeciesSearch.tsx
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.

What changes when another requirement arrives?
The changeBuilt as the desk opensBuilt on the first lookup
Every shift looks something up in its first minuteSimple: the cost is paid before anyone waits.The same work, paid by the first intake instead.
The export may not have finished syncingThe 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 requestsBuilt before anyone shares it: nothing to coordinate.First lookups race: guard the slot, or two builds run.
Care sheets change during the shiftNeither 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?

A new lookup is built for every species request. The lazy helper caches correctly. Where should you look first?
A dispatch board shows every center’s open intakes on its first screen, every time it opens, and building that list takes about a millisecond. When should the list be built?
A caption-review panel builds a timecode-to-cue lookup when a reviewer opens captions. Reviewers close and reopen the panel, and switch videos in the same player. Where should the lookup’s slot 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