Definition
The Singleton pattern ensures a class has only one instance and gives every caller one point of access to it.
After Gamma et al., Design Patterns (1994), Singleton
01Recognize the situation
Another lookup should return the count we already changed.
A catalog command runs Search and then Export, and both call the same paid API. The account
allows three attempts per run. A local counter is enough when Search is the only caller.
Once Export joins the same run, its attempts must spend that allowance too. The first
version gives each caller its own counter (a fragment: the createBudget counter it
uses is in the program below):
// Search and Export each keep their own budget of three attempts.
const searchBudget = createBudget(3);
const exportBudget = createBudget(3);
function search(): Admission {
return searchBudget.tryTake();
}
function exportResults(): Admission {
return exportBudget.tryTake();
} That is clear, and each caller is easy to test on its own: hand it a fresh counter. It is right while each caller’s allowance really is its own. Here it is not. A second counter gives Export a second allowance, and the run can make six attempts.
A Singleton provides a shared access point that retains one instance. Asking again returns the same object, with its current state. The familiar class form keeps its constructor private and returns the remembered object from a static method.
The important question is one for whom? One module, application run, test, and server process are different boundaries. Start with the callers who must share; then choose where their instance lives. You already use access points like this, each with its own boundary:
| Access point | One instance per | Who can change it |
|---|---|---|
| import { db } from './db.ts' | Each evaluated copy of the module: one per browser tab, one per server process. | Anything that imports it. |
| globalThis | Each JavaScript realm: a tab, a worker, a Node process. | Any code running in that realm. |
| localStorage | Each origin in a browser profile, shared by all its tabs. | Any page from that origin. |
02Give the count an owner
Search and Export need one budget between them.
The access point creates the budget on first use and returns it on every later call. Search and Export each ask it for the budget immediately before their API attempt. A refusal means skipping that attempt; an admitted request spends a unit even if the request later fails. This is the whole program the lab runs: the budget, the access point, and the two callers taking four attempts between them.
type Admission = { allowed: boolean; remaining: number };
type Budget = { tryTake(): Admission };
// A budget of attempts, spent one at a time.
function createBudget(limit: number): Budget {
let remaining = limit;
return {
tryTake() {
const allowed = remaining > 0;
if (allowed) remaining -= 1;
return { allowed, remaining };
},
};
}
// The access point: created on the first call, returned ever after.
let appBudget: Budget | undefined;
function getAppBudget(): Budget {
appBudget ??= createBudget(3);
return appBudget;
}
// Two callers, one allowance: each asks the access point.
function search(): Admission {
const budget = getAppBudget();
return budget.tryTake();
}
function exportResults(): Admission {
const budget = getAppBudget();
return budget.tryTake();
}
const first = search();
const second = search();
const third = exportResults();
const fourth = exportResults();
const show = (name: string, a: Admission) =>
console.log(`${name}: allowed=${a.allowed} remaining=${a.remaining}`);
show('search', first);
show('search', second);
show('export', third);
show('export', fourth); | Part | Owns | What it handles |
|---|---|---|
| Budget | Admission | The count, and the check and the decrement that spend it. |
| Access point | Which callers share | Creates the one budget, or holds it, and returns it to every caller. |
| Callers | The request and its result | Ask for admission before each API attempt; a refusal means skipping it. |
What a production singleton addsValidation, scopes and a lock for creation
The same budget, checked and scoped. A limit outside 0 to 100 is rejected when the budget
or its scope is made, before anyone spends from it. A scope (createBudgetAccess) retains one budget and returns it on every call: the application’s scope is one owner,
and a test or a second account run can make another. The practical version enforces one
instance per scope, rather than banning every other budget.
Object.freeze stops a holder replacing the methods. Exporting one accessor
gives importers global access within that evaluated module; calling createBudgetAccess again gives another owner.
export type Admission = Readonly<{ allowed: boolean; remaining: number }>;
export type Budget = Readonly<{
tryTake(): Admission;
remaining(): number;
}>;
function checkLimit(limit: number): void {
if (!Number.isInteger(limit) || limit < 0 || limit > 100) {
throw new Error(`limit must be an integer from 0 to 100: ${limit}`);
}
}
export function createBudget(limit: number): Budget {
checkLimit(limit);
let remaining = limit;
return Object.freeze({
tryTake(): Admission {
// No await or callback between the check and the decrement.
const allowed = remaining > 0;
if (allowed) remaining -= 1;
return { allowed, remaining };
},
remaining: () => remaining,
});
}
// One budget per accessor: each call to createBudgetAccess is a new owner.
export function createBudgetAccess(limit: number): () => Budget {
checkLimit(limit);
let instance: Budget | undefined;
return () => (instance ??= createBudget(limit));
}
// The application's accessor: one per evaluated copy of this module.
export const getAppBudget = createBudgetAccess(3);
export function search(): Admission {
return getAppBudget().tryTake();
}
export function exportResults(): Admission {
return getAppBudget().tryTake();
} 03Predict what a caller holds
Asking again returns the same budget. Does a copy of it?
Experiment
Two callers, one budget
// In exportResults, line 29 becomes a copy, as immutable updates teach:
const budget = { ...getAppBudget() }; Reading the TypeScript??=, closures, and synchronous work
appBudget ??= createBudget(3) assigns only when appBudget is null or undefined, so the first call creates the budget and
every later call returns it. The count lives in createBudget’s remaining variable, which only the returned tryTake can reach: a
closure.
The check and the decrement contain no await or callback, so another JavaScript
task cannot enter halfway through them in this execution context. That guarantee stops at
the realm: a worker, or a second copy of the module, has its own budget.
The class form is static #instance plus getInstance() returning this.#instance ??= new Budget(), with a private constructor.
TypeScript’s private is checked at compile time only; #instance is private at run time too.
04Meet it in your language
The same sharing can reach further than you meant.
In JavaScript a module is the everyday singleton: it is evaluated once per runtime, and
every importer shares its bindings. In a browser that runtime is one tab, so a
module-level cache holds one user’s data. On a server it is the whole process, and the
module’s state is shared by every request and every user. Below, Alice’s request caches /api/me, and the next user to ask is served her profile. Testing in the browser hides it,
because each tab evaluates its own copy.
// Fetches JSON from the backend with the reader's token (not shown).
declare function fetchJSON(path: string, token: string): Promise<unknown>;
// Before. Created when the module is first imported: a server process has
// one, so every request, and every user, shares this cache.
const sharedCache = new Map<string, unknown>();
export async function getJSON(path: string, token: string): Promise<unknown> {
if (!sharedCache.has(path)) {
sharedCache.set(path, await fetchJSON(path, token));
}
return sharedCache.get(path);
}
// After. Each request makes its own client, and the cache ends with it.
export function createApi(token: string) {
const cache = new Map<string, unknown>();
return {
async getJSON(path: string): Promise<unknown> {
if (!cache.has(path)) cache.set(path, await fetchJSON(path, token));
return cache.get(path);
},
};
} Keep request-specific state with the request. SvelteKit’s state-management guide warns that shared server variables span users, and keeps request-owned services on event.locals; TanStack Query’s SSR guide explains that a client created at the file root shares its cache between all requests, and Redux asks for a store per request. In the browser
a module-level client is fine: it is still a singleton, one per tab.
05Compare the owners
Sharing once does not require global access.
A module or package accessor can fit a stable service every caller should share for that runtime. Its cost is a dependency callers can fetch without declaring it. Constructing once and passing the instance is often enough when a run has a clear owner.
| The next requirement | One global accessor | Construct once and pass it |
|---|---|---|
| Export joins Search’s run | Both fetch the retained budget. | Bootstrap passes the same budget to both. |
| Two independent account runs overlap | They spend one count unless access becomes scoped. | Each run creates a budget; its callers share that budget. |
| The program runs as three server processes | Three budgets, one per process: each allows three. | Also three. One quota for all of them needs an authority outside the processes. |
| The service must shut down | An owner still needs to coordinate shutdown and held references. | The lifecycle owner can stop work and close its instance. |
Explicit passing adds parameters and setup. A global accessor makes access shorter but hides the owner. The rule for what goes in the shared instance: only the state that must be one across the boundary, here the count. Whatever differs per caller, per request or per test stays with that caller.
Safe initialization and safe use are separate obligations. ??= makes construction happen once in one realm; the budget’s check and decrement
stay safe only because nothing awaits between them. Sharing an instance does not automatically make every operation on it safe.
Its neighbours: Multiton keeps one retained instance per key within an owner; Lazy initialization decides when an instance is built; Object pool shares several instances one borrower at a time; Factory puts creation in one place and may return a fresh instance on every call; and dependency injection is how an owner hands the one instance to its callers.
Construction timing is a separate decisionEager setup, failure, and asynchronous work
Singleton answers how access reaches one instance. Lazy initialization answers when it is built. Eager setup can expose a bad configuration before work begins. Laziness can avoid building something never used.
A getter can return an error or a promise. If construction later opens a connection, define what callers share while it is pending and what follows a failure. In JavaScript, storing the pending promise before awaiting it makes callers share one attempt. Keeping a rejected promise makes the failure sticky; clearing it permits another attempt and therefore needs a retry policy.
What if the shared object needs to close?Shutdown and references that outlive a reset
This counter owns no socket or listener. A shared pool, client, or logger does. Put shutdown with the owner: stop new work, finish or cancel in-flight work, and close once according to that resource’s API.
Clearing a cached reference does not revoke references already handed out. Replacing a global can leave old callers using one instance while new callers receive another. A restartable resource needs a lifecycle contract, including what old handles do after close.
06Make the call
What should the next test share?
Two decisions in other settings. In each, name who must share, and what is one per what.
Before you open the answer below, say it in your own words: who owns the lesson’s budget, which callers share it, and what should a second test, a second user’s request or a second server process get?
One way to say itWho shares, where the owner lives, what ends it
One instance needs an owner: repeated access preserves its state, and the owner decides who shares it. Search and Export share one budget because they belong to one run; the run, or the application bootstrap, owns it. A second test should own its own budget and pass it to its callers: another lookup of the global returns the exhausted one, and resetting the global between tests breaks a test that is still using it. A second user’s request gets its own request-scoped state; a second server process already has its own copy, so a quota across processes needs a shared authority, not a singleton. Share only what must be one across the boundary (the log file, not the request id), and when nothing changes and nothing needs closing, a constant is enough. Name who creates the instance and what ends its lifetime; then add a second test, user or runtime, and ask which callers should still see each other’s changes.
Practice in the lab
An observatory runs a capture session per camera each night. The session’s acquisition panel and its file recorder both number the frames they take, and every frame a camera takes needs its own number. Like the lesson’s budget, the sequence is one instance that several callers must share, and the question is again who shares it. Each level starts from the previous level’s finished code, so you can begin at any of them.
- SINGLETON-101 (easy) The panel and the recorder hand out the same frame numbers
- SINGLETON-102 (medium) A session reads the tape it does not need, and too early Pro
- SINGLETON-103 (hard) Review: the second night reuses the first night’s numbers Pro