← Design patterns
Creation First demand, remembered result

Lazy initialization

Keep the recipe until it is needed.

Someone opens your help center, reads how to reset a password, and leaves. They never touch search. Yet opening the center built a search index for every article before they read the first one.

The index is useful when someone searches. We can keep the instructions for building it, wait for that first search, and reuse the result afterward.

TypeScriptGoPython One help center · one initialization outcome

01 / Notice the unused work

Opening the page does not always mean using search.

Building the index at startup is a reasonable first choice. Search is ready immediately, initialization errors appear early, and the rest of the program can assume setup has happened. That approach is called eager initialization.

Now imagine a help center whose common path is following article links. Index setup becomes work every visitor pays for, even when their visit never needs it. The useful change is to move that setup behind the operation that requires it.

Lazy initialization delays creating a value until its first demand, then retains the result for later use. Our help center keeps its article records immediately and builds its search index only when a nonblank search needs it.

If you write React, you have already handed it a recipe like this: useState(() => readDraft()) runs readDraft once, for the first render, where useState(readDraft()) runs it on every render.

We use four small articles and a real, deliberately simple term index. The lab counts actual builds rather than timing them; a large index would need measurement before you decide to defer it.

02 / Move the creation boundary

Keep a recipe and a place for its result.

The center keeps a recipe for building its index and a place to remember the outcome. The implementations retain an initialization function and supply the recipe to a retained cell when needed. Creating the wrapper or empty cell does not build the index. First demand runs the recipe. Later calls receive the remembered outcome.

Open → keep recipe

Uninitialized.

The articles and builder exist. The search index does not. Browsing can proceed without it.

First demand → initialize

Run the setup.

A nonblank search calls the retained wrapper. This caller pays the construction cost.

Later demand → reuse

Keep the outcome.

Different queries use one index. A new query does not mean a new initialization.

The phrase “once” needs an owner. Here it means once per help-center instance. Two centers can build two independent indexes. Recreating the wrapper inside every search would give every search its own empty place to cache a result.

Initialization state is also separate from the value. An index with zero terms is a completed, usable index. A stored false, zero, or empty string can be a valid result in other examples. None of those values should accidentally mean “run setup again.”

03 / See the shape

Create the wrapper once. Call it when search needs it.

The basic form retains one initialization outcome. The useful form gives that wrapper a home inside HelpCenter. Browsing copies article records without touching the index; searching asks for it. The separate warm method lets an owner demand setup deliberately before a user searches.

Expected setup failure is part of this example’s result contract. We remember “index unavailable” just as we remember a successful index. That prevents repeated searches from silently repeating a failed build. Recovery needs a deliberate new attempt boundary.

Keep one initialization outcome. TypeScript uses a closure and an undefined sentinel; Go uses sync.OnceValues; Python and Java keep an explicit initialized flag behind a lock; Rust uses OnceLock. Expected failure is a stored outcome too.

TypeScriptReading
search.ts
export function lazyResult<T>(initialize: () => Outcome<T>): () => Outcome<T> {
	let cached: Outcome<T> | undefined;
	return () => (cached ??= initialize());
	// Store the outcome, including an expected error. Undefined alone means "not run".
}
GoAlongside
search.go
func LazyResult[T any](initialize func() (T, error)) func() (T, error) {
	return sync.OnceValues(initialize)
	// The returned function shares one value/error pair, including a non-nil error.
}

The center snapshots article records when it is created, so later caller mutations cannot change what first initialization sees. Its index maps ASCII words to article positions. Search trims ASCII whitespace, lowercases A–Z, and looks up one exact term. “payment” matches articles 2 and 4; “offline” matches 3. Repeated words in one article do not duplicate that article in the results.

A blank query returns no results without demanding the index. An unmatched word also returns no results, but needs the index to determine that. This is a small exact-word lookup; ranking, stemming, phrase search, and Unicode-aware analysis are separate search requirements.

Reading the TypeScriptA closure, a sentinel, and a tagged outcome

cached lives in the closure returned by lazyResult. The ??= operator calls and assigns the initializer only when cached is nullish; our sentinel is undefined. Once assigned, the wrapper returns the same outcome object.

The ok tag distinguishes a successful value from an expected error. An empty index remains ok: true. This is a synchronous helper.

Reading the GoOnceValues retains the value/error pair

sync.OnceValues returns a function that executes its initializer once and retains both returned values. Here they are *SearchIndex and error. A non-nil error is still a returned value, so it remains part of the cached pair.

The standard wrapper handles concurrent first calls. Our built index has no mutation API, and searches return copied article values. The attempt counter is atomic so the native concurrency check can inspect it safely. Keep a pointer to the center; do not copy a value containing its atomic state after use. Read OnceValues.

Reading the PythonA locked outcome and an explicit state flag

LazyResult keeps an explicit _initialized flag separate from its cached outcome. That matters because a successful value can be false, zero, empty, or otherwise valid. Its lock keeps concurrent first callers inside one initialization boundary.

HelpCenter snapshots its article list before creating the wrapper. A blank search returns immediately, while get stores an expected error as an outcome. Python’s dataclasses and lists make the copied value boundaries visible.

04 / Predict, browse, search

A visitor can leave without ever building the index.

Watch the first-demand story or step through it. In Try it, start with the lazy policy. Predict zero builds, then browse articles. Next predict one and search “payment.” Change the query to “offline” and search again: the result changes while the build count stays at one.

Switch to eager setup and inspect the count before doing anything. Then try the mistaken wrapper lifetime: each search builds again, even though each individual wrapper correctly initializes only once.

Finally, try the empty help center and a setup failure. A successful empty index is ready with zero terms. A failed attempt is a remembered error. These should be different states even when neither shows search results.

Lazy initialization

Build on first demand.

Help center 14 article records
Article snapshot Available to browse
Retained setup slot Recipe only buildIndex(articles) Not called yet
Opened help center
Returned article IDsNot requested

Browse or search to request articles.

Total build attempts 0This owner’s builds 0
01/ 04
Open the help center

Keep the recipe.

Help center 1 owns four articles and an initializer. No index exists yet.

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

Read this scene

Help center 1 owns four articles and an initializer. No index exists yet.

Help center 1: 4 articles. Opened help center. Index uninitialized, 0 terms. 0 total build attempts; 0 in this owner. No result requested.

Worked trace: open → browse → search payment → search offline
PolicyAt openingAfter browsingAfter first searchAfter second search
Eager1 build111
Lazy, retained wrapper0 builds011
New wrapper per search0 builds012

The lab uses the same TypeScript center and index as the code views. Eager mode calls warm when opening. The mistaken mode creates a new center inside each search handler. Changing a setting or opening a fresh center resets the owner and its counts.

05 / Review the first demand

What exactly does this owner remember?

Review the wrapper’s lifetime, its failure policy, and the extra state needed when initialization becomes asynchronous.

Search builds the index again on every keystroke. The lazy helper itself caches correctly. Where should you look first?

06 / Give first use a caller

The work moved. Somebody still waits for it.

In an application, a route owner could create one help center when its article snapshot becomes available. Article links call browse; the search form calls search. The route retains that owner across searches and replaces it when switching to another help-center dataset.

That placement decides who pays. Eager setup puts work at opening. Lazy setup puts it at the first search. If setup is expensive and synchronous, it can still block the browser at that moment. Lazy initialization alone neither moves work onto another thread nor makes construction cheaper.

You might prewarm after initial content is usable, or when a visitor signals an intent to search. Our warm method expresses that choice. Prewarming spends work earlier and may spend it unnecessarily; it trades some of lazy initialization’s avoidance for a more predictable first search.

Now articles change. The cached index still describes its owner’s original snapshot. Lazy initialization supplies no automatic invalidation. Creating a new center for the new snapshot keeps that rule explicit. An incrementally updated index is another design with update and synchronization responsibilities.

A shared service also needs the right scope. Two tenants with different articles cannot accidentally use the same cached index. A stable route, dataset version, or service instance can be an owner; a global variable is useful only when global sharing matches the data contract.

When initialization becomes asynchronousRemember the work in progress, too

Suppose the index must be loaded or built in a worker. If you only cache its final value after awaiting it, a second caller can arrive while the cache is still empty and start another initialization. Store the pending promise immediately so both callers share the same attempt.

This TypeScript extension defers the initializer into a promise callback and stores that promise first. It retains a rejection as well as a success.

async-lazy.ts
// TypeScript extension: share pending work and retain success or rejection.
export function lazyAsync<T>(initialize: () => Promise<T>): () => Promise<T> {
	let pending: Promise<T> | undefined;
	return () => (pending ??= Promise.resolve().then(initialize));
	// Store the promise before initialize runs. There is no await-before-store gap.
	// A rejected promise stays cached. Cancellation and retry are separate policies.
}

A retrying wrapper would need an explicit policy for clearing a failed attempt. Cancellation needs an owner too: one departing caller should not blindly abort work that other callers still await.

Failures, recursive demand, and lifetimeChoose the guarantee you actually need

Our expected failure is a returned value. Unexpected exceptions and panics have different native behavior: the TS assignment does not complete after a throw, while Go’s OnceValues replays a panic on later calls. Those mechanisms do not provide one interchangeable retry contract. Go’s panic contract describes the difference.

The initializer must not demand its own unfinished value, directly or through a dependency cycle. A cycle such as search setup → suggestions setup → search setup can recurse or deadlock depending on the mechanism. Keep these initialization dependencies explicit.

Retaining the result also retains its memory for the owner’s lifetime. Lazy creation does not implement eviction or resource disposal. If the value later owns a worker, file, or connection, decide when that owner releases it and what future demand means after disposal.

Build UIs?useState(() => …) is a recipe React keeps for you, and one day a heavy panel will need a load you remember yourself.

Where it already is in your components

React calls your component function on every render, so everything written in it runs again, including the argument you pass to useState. React keeps only the first value. Its docs name the cost: “Although the result of createInitialTodos() is only used for the initial render, you’re still calling this function on every render.” Pass the function itself and “React will only call it during initialization.” That is this lesson’s pattern with a component instance as the owner. A ref filled only while ref.current is null, as the useRef docs show, is the same idea written by hand.

In a React 19.1.0 development mount without StrictMode, the composer below called readDraft six times for its first render and five keystrokes. Given a function instead, React called it once. StrictMode renders twice in development, which made those counts 12 and 2.

A Svelte component’s <script> runs once per instance, not on every update, so let text = $state(readDraft()) already reads storage once: once in a Svelte 5.57 mount across the same five keystrokes. The lazy part of Svelte is $derived. It keeps its expression, and “derived values are not re-evaluated until they are actually read.” The preview’s derived ran zero times while the preview was closed and once when it opened. A derived that nothing reads never ran at all.

When you have to own it

Now the composer gets an emoji picker. Its component and emoji data are a large download, and most messages are sent without opening it. Load it with import() the first time someone opens the panel, and remember the promise, so reopening the panel, a second composer on the page, and a hover that starts the download early all share one load. That is lazyAsync from “When initialization becomes asynchronous” above, with a module as its value.

React’s lazy remembers too. React will not call the loader “until the first time you attempt to render the returned component,” and caches both the promise and its resolved value. It needs a stable owner, which is why the docs say to declare it at the top level of a module. Declared inside a component, every render makes a new wrapper, like the search in section 04 that recreated its center: in React 19.1.0, each re-render of the parent called the loader again and showed the Suspense fallback again. Svelte has no wrapper to misplace, but {#await import(…)} inside {#if open} calls import() again each time the panel opens, so the promise gets its home in <script module>, which runs once for the whole app.

A failure is an outcome too, and here more than one layer remembers it. lazy keeps a rejection: after a failed load, remounting the error boundary showed the same error without calling the loader. The browser can keep it as well. The HTML standard removes a failed fetch from the module map, so by the standard a later import() fetches again. Chromium 153 did not: after a 503, a 404, or a load while offline, importing the same URL again rejected with “Failed to fetch dynamically imported module” and sent no request until the page reloaded. So both samples remember the failure and offer a reload, not a Try again button that cannot succeed there. A new page is a new owner, the same boundary as a new help center.

A composer that restores its saved draft. React computes the useState argument on every render unless you pass a function; Svelte’s script runs once, and its derived preview runs only while the preview is open.

ReactAlready in your code
Composer.tsx
import { useState } from 'react';
import { previewText, readDraft, saveDraft } from './components/draft';

export function Composer() {
	// React calls this function on every render, so the argument below is
	// computed every time: readDraft() reads and parses storage on each
	// keystroke, and React keeps only the result from the first render.
	const [text, setText] = useState(readDraft());
	// Pass the function itself, useState(readDraft), and React calls it once,
	// while initializing. A ref filled only while ref.current is null is the
	// same idea written by hand.
	const [previewing, setPreviewing] = useState(false);

	return (
		<form>
			<textarea
				value={text}
				onChange={(event) => {
					setText(event.target.value);
					saveDraft(event.target.value);
				}}
			/>
			<button type="button" onClick={() => setPreviewing(!previewing)}>
				Preview
			</button>
			{/* Computed inside the branch, so only while the preview is open. */}
			{previewing && <p>{previewText(text)}</p>}
		</form>
	);
}

07 / Recognize the boundary

A useful tool can keep its setup behind first use.

A compiler front end might defer a lookup table until an optional analysis needs it. An application might build a parser, formatter, or search index on first demand. The useful clue is a reusable value with setup that some callers never need.

Go has a wrapper for once-only returned values.

sync.OnceValues is the actual primitive used in our Go program. Its documentation includes reading file contents on first use. The useful connection is that the returned function retains the initialization result for its callers, including concurrent ones.

Read the OnceValues API and example ↗

An initialized value can live in a dedicated cell.

A dedicated cell can separate an empty state from one containing its initialized value. The important policy is the same: store the outcome you want later callers to observe, including expected failure.

08 / Choose when to pay

Delay optional work when first use can carry it.

Lazy setup is a good candidate when a meaningful share of visits never needs the value, initialization has a cost worth avoiding, and callers can handle the first-use delay or failure. Keep the owner visible so you can explain what is shared and how long it remains valid.

Eager initialization may be clearer when the value is always needed, predictable request latency matters more than startup, or setup errors must prevent the service from accepting work. For a cheap value, the branch and lifecycle machinery may not earn their place.

Related questions, different responsibilities
QuestionIdea to consider
When should this value be created?Lazy or eager initialization.
What construction rules belong together?Factory.
How many instances share one scope?Singleton or an explicit owner.
Can results be reused for equal arguments?Memoization, with an argument key.
Who can borrow a reusable resource now?Object pool, with acquire and return.

Here “payment” and “offline” still perform different lookups. We retain the index, not every query’s answer. That distinction keeps lazy initialization separate from a growing query-result cache.

09 / Take the idea with you

Explain the visit that does zero setup.

Try explaining the help center without the pattern name: opening keeps the articles and a recipe; browsing never calls it; the first search builds one index; later searches use that same index for different questions.

Then change one requirement. The index now loads asynchronously, two searches arrive together, and the first attempt fails. What does the owner remember while loading? What does it remember afterward? Who can start the next attempt? Those answers define the behavior beyond a null check.

Connections to follow nextRelated lessons

Factory can supply the construction recipe. Singleton separates shared scope from creation timing. Object pool adds borrowing and return to reusable resources. Each answers a different question about an object’s life.