01 / One count, shared callers
Getting it again should mean getting the same one.
A local counter is a reasonable start when Search is the only caller. Export introduces the pressure: the allowance belongs to their whole run. Copying Search’s setup also copies its allowance.
Singleton controls access to one instance and gives callers a shared way to obtain it. In the familiar class form, the constructor is private and a static method remembers the instance. Later calls return that same object, including the state earlier callers changed.
Keep three decisions separate. Identity asks whether callers receive the same object. Access asks whether they fetch it globally or receive it from an owner. Lifetime asks when it is created and when it stops being used. Singleton often bundles these decisions together; we can reason about each one.
Ask for the budget.
Search and Export use the same access point. Repeated access preserves identity.
Keep one reference.
The first lookup creates the budget. Later lookups return it without refilling it.
Protect one count.
Each admitted attempt spends one unit. An empty budget refuses the next attempt.
This example is a finite attempt allowance for one run: it spends and refuses, but never refills. It is small enough to trace, and it makes the consequence of a second instance visible.
02 / See the shape
Follow the reference, then the changing value.
Start with the basic policy: two lookups return the same object with a limit of three. The useful version adds a mutable balance and a decision: allow one attempt, or refuse it. The caller view then shows the same budget being passed explicitly.
Limits are integers from zero through 100. Invalid limits fail during setup. A successful
take returns allowed: true and the balance after spending one unit. At zero, it
returns allowed: false with zero unchanged. Reading the balance consumes nothing.
Repeated access returns the same budget policy. TypeScript shows a private-constructor class, Go uses sync.OnceValue, Python caches a class instance, Java uses the holder idiom, and Rust uses OnceLock. This view proves identity before adding mutable state.
export class BudgetPolicy {
static #instance: BudgetPolicy | undefined;
readonly limit = 3;
private constructor() {}
static getInstance(): BudgetPolicy {
return (this.#instance ??= new BudgetPolicy());
}
}
// BudgetPolicy.getInstance() === BudgetPolicy.getInstance() type budgetPolicy struct{ limit int }
var getBudgetPolicy = sync.OnceValue(func() *budgetPolicy {
return &budgetPolicy{limit: 3}
})
// getBudgetPolicy() == getBudgetPolicy() The practical source deliberately keeps a factory for independent runs and tests. Each accessor manages one instance; it does not forbid every other budget in the program. Publishing one accessor at module or package scope gives it application-wide reach within that runtime’s copy of the code.
Reading the TypeScriptPrivate construction, closures, and synchronous work
The basic class’s private constructor prevents ordinary TypeScript callers from using new. Its static #instance holds the remembered object; ??= assigns only when it is missing. TypeScript’s readonly is a compile-time restriction, not a runtime freeze.
In the useful version, a closure keeps the count private. Object.freeze prevents replacing the exposed methods; those methods can still change the captured count.
The check and decrement contain no await or callback, so another JavaScript task
cannot enter halfway through them in this execution context.
Reading the GoOnce for construction; a mutex for use
The basic sync.OnceValue accessor remembers a pointer. The useful scope
uses sync.Once and a stored pointer so the ownership is visible. Both
coordinate initialization. The returned *Budget is shared, so TryTake separately locks the check and decrement.
defer releases the mutex as the method returns. Pass these objects by pointer:
a used mutex or Once must not be copied. Integer input is enforced by the signature; invalid
ranges return an error. The policy’s unexported field is a package boundary, not protection
from code inside that package.
Reading the PythonClass attributes, locks, and explicit scopes
Python can keep a class-level instance, but that is a policy choice rather than a
special language guarantee. The practical example makes the owner explicit with BudgetScope: its lock covers lazy construction, while the budget's lock covers admission and
inspection.
The dataclass report is a value captured at admission. with releases a lock when the block exits. Python's module-level accessor is
one accessor in one evaluated module; another process or separately loaded module need not
share it.
03 / Follow the allowance
Does Export see what Search spent?
Watch the shared count, or step through it. Predict four outcomes: Search, Search, Export, Export. With one budget, the fourth call is refused. In Try it, give each caller its own instance. The code inside each budget stays the same; the total permission changes.
The third arrangement models two runtimes. Each has a perfectly ordinary local singleton. Watch why “one in each runtime” still adds up to two.
One instance. One allowance.
Both callers share Budget A. Budget A: 3 of 3 attempts left. No attempts yet. Admitted 0; refused 0.
Same runtime
SearchUses Budget ASame runtime
ExportUses Budget AOne count for Search + Export
Both callers see one allowance.
The same accessor returns Budget A, with three attempts total.
Reduced motion: choose a scene to see its completed state.
Read this scene
The same accessor returns Budget A, with three attempts total.
Both callers share Budget A. Budget A: 3 of 3 attempts left. No attempts yet. Admitted 0; refused 0.
04 / Try a decision
The first test leaves something behind.
The same sharing that makes the production count useful can connect unrelated tests. A test that passes alone may find a different balance when another test runs first. Choose a lifetime that matches the work being tested.
05 / Give it a real job
Create it where the run begins.
Imagine a command that searches an external catalog and exports selected results. Its bootstrap reads the allowed attempt count, validates it, creates one budget, and passes it to both operations. Each operation asks for admission immediately before attempting the API call. A refusal means skipping that call.
The budget owns admission. The caller owns making the request, interpreting its result, and reporting a refusal. A failed request still spent an attempt under this contract. A cancellation after admission also spends it; refunding would be a different policy.
Now the command grows to handle two independent accounts. A global accessor would silently make them consume one allowance. Creating one budget per account run makes that boundary explicit. Changing the owner changes who shares; Search’s admission logic can stay the same.
Passing the object is dependency injection in its everyday form. One shared instance can still serve the entire run, while the function signature tells you it depends on a budget. That signature also gives a test somewhere to supply its own instance.
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. Our accessor creates on first lookup; the explicit caller example constructs at startup. 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 can make callers share one attempt. Keeping a rejected promise makes the failure sticky; clearing it permits another attempt and therefore needs a retry policy.
Initialization primitives have different failure rules. Go’s OnceValues remembers all returned values, including an error. Read the Go contract before choosing 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.
Resetting a cached reference requires an ownership rule. It is different from clearing a shared static while readers retain references, so define what old handles do after close.
Build UIs?Every request your server renders imports the same modules, and one day you will decide who shares a client.
Where it already is in your components
You probably already follow this rule: on a server, user data never goes in a module-level
variable. SvelteKit’s docs show a let user in a server file and warn that “the user variable is shared by everyone who connects to this server”. Svelte’s docs add that module state changed during server rendering “may be accessible by the next user.” The reason is this lesson’s accessor, run by the platform.
A JavaScript module is a Singleton the runtime keeps for you. The first import evaluates the file, and every later import of the same module returns
that evaluated instance, along with the objects it created. In the browser the instance
lasts as long as the page, so a module-level store is one per tab, which is usually what
you want. On the server, SvelteKit loads each route’s code through an import it remembers after the first call, so the instance lasts as long as the server process and every request shares it.
The component below keeps the signed-in name in <script module>, which
runs once per module rather than once per component. Render it for Alice, then for a
visitor with no session: the second server response also says “Signed in as Alice.” After
hydration the browser’s own copy of the module shows “guest”, so the leak is easy to miss
while you test in one tab. A module-level store written during a React server render leaks
the same way.
Development adds one more copy. When you save a module, Vite evaluates it again and its
importers move to the new instance, while a timer the old instance started keeps running
until import.meta.hot.dispose stops it.
<script module lang="ts">
// Evaluated once per module instance: one per browser tab, one per server process.
const viewer = $state({ name: 'guest' });
</script>
<script lang="ts">
import { page } from '$app/state';
// Breaks on the server: the next visitor's render starts from this name.
if (page.data.user) viewer.name = page.data.user.name;
</script>
<p>Signed in as {viewer.name}</p>
When you have to own it
Now your app gets a data client with a cache, such as a query client or an API client that
carries the reader’s session. The server render and the browser both need one, and you
decide what “one” means. export const client = createClient() is one per
server process. TanStack Query’s SSR guide warns that a client created at the file root “makes the cache shared between all requests”,
and Redux’s Next.js guide says “the store should
be created per request.”
Give it an owner, the way the two accounts each got their own budget. In server code, the
request is the boundary: create the client on first use and remember it on event.locals,
which is our accessor’s ??= with a request in place of the module. This site’s
server composition root works that way and keeps no user or client in module state. In components,
the render is the boundary: create the client in the root layout and pass it down through context,
which Svelte’s docs describe as “not shared between requests”, so each server render and each
tab gets its own. In React, keep it in component state and pass it through a provider. The code
that uses the client stays the same; the owner decides who shares it.
06 / Already in your toolbox
The access point need not be a class.
These APIs are building blocks for sharing an instance; the lifetime it gets is still your decision.
A JavaScript module can hold the reference.
An exported binding such as getAppBudget is available to importers of that module.
Reusing the same evaluated module lets them reach its retained instance. A separately loaded
copy, another tab, or another runtime can own another.
Initialization and usage are separate decisions.
sync.Once and sync.OnceValue coordinate initialization. A local cell
can use the same primitives without being globally reachable.
07 / Make the call
Name the boundary before choosing the mechanism.
A global accessor can suit a deliberately shared, stable application service. Its cost is that callers can reach it without declaring the dependency. Mutable state, independently configured runs, and tests make that cost easier to notice.
| The requirement | A useful starting point | The reason |
|---|---|---|
| One fixed value | An immutable value or constant | No mutable identity or lifecycle needs managing. |
| One service shared for a whole runtime | A module or package accessor | Global reach can fit if all callers should share its configuration and lifetime. |
| One instance per application run or test | Construct once and inject | The owner controls sharing and can supply a fresh fixture. |
| One per account or region | Explicit scopes or a keyed instance store | Identity includes a key. A keyed store also needs eviction and cleanup rules. |
| One quota across several servers | A shared authority with atomic admission | Local counters cannot enforce a system-wide total on their own. |
A connection pool illustrates another distinction: one pool object can manage several connections. Sharing the manager says nothing about the number of resources inside it. Likewise, a logger’s shared identity does not by itself guarantee safe writes or an orderly flush.
08 / Take the idea with you
“One” needs a place and a lifetime.
Explain the useful idea without its name: “These callers need to see each other’s changes. Their access point retains one instance, so asking again returns the current state. An owner decides which callers belong together.”
From memory: does another lookup refill the budget? Does safe initialization protect every later mutation? Do two server processes share the same count? Every implementation answers no in this lesson; each missing guarantee requires its own decision.
Pick a shared object in your application. Name its callers, where it is created, and what ends its lifetime. Then imagine a second test, user, or runtime. Which should share, and which should receive a fresh instance?
Connections to follow nextRelated lessons
- Factory puts creation decisions in one place; it may return a fresh instance on every call.
- Registry resolves names to registered collaborators. Its owner can choose a local scope.
- Composition over inheritance explores assembling explicit collaborators.
- Multiton extends shared identity to one retained instance per key within an owner. Lazy initialization explores construction timing. Dependency injection explains how a caller supplies the shared policy.