The question this answers
What happens when two threads both discover that the thing they need does not exist yet?
The first two requests after a deploy, both calling getConnectionPool() on a pool that is created lazily on first use.
The instance slot holding the pool reference, and the pool object itself once it exists. Both are read by every request and written exactly once — in theory.
Exactly one pool is ever constructed, and every reference handed to a caller points at a pool that is fully constructed — never one whose fields are still being assigned.
Synchronization exists to preserve this sentence. If a schedule can make it false, the code is wrong no matter which primitive it uses.
Two threads, one missing object
The lazy singleton is a check-then-act, and it is the most-written check-then-act in software: read instance, find it null, construct, assign. Between the read and the assignment there is a window, and under concurrent first use two threads land inside it. Both see null, both construct, both assign — and only one assignment survives.
Two objects is the *benign* outcome, and even that is rarely benign. Two connection pools means twice the configured connections against a database sized for one pool. Two metrics registries means half the counters are never scraped. Two caches means a 50% hit-rate cliff nobody can explain. And because the loser is garbage collected, the evidence disappears.
The dangerous outcome is worse and less obvious: publishing the reference before the object is finished. A compiler or CPU may make the write to instance visible before the writes to the object's fields are visible, so another thread reads a non-null reference and dereferences a half-built object. This is the reason Double-Checked Locking: The Canonical Cautionary Tale is a cautionary tale rather than an optimization, and the reason Safe Publication: Handing Over a Finished Object is a separate concept from mutual exclusion.
| # | Request thread 1 | Request thread 2 | State |
|---|---|---|---|
| 1 | read instance -> null | · | instance=null |
| 2 | · | read instance -> null | instance=null |
| 3 | new Pool(max=20) — opens 20 sockets | · | instance=null sockets=20 |
| 4 | · | new Pool(max=20) — opens 20 more sockets | instance=null sockets=40 ✕ Two pools exist. The database sees 40 connections against a configured maximum of 20 and starts refusing. |
| 5 | instance = poolA | · | instance=poolA sockets=40 |
| 6 | · | instance = poolB | instance=poolB sockets=40 |
| 7 | --- second failure: publication, with a lock and a fast-path read --- | · | instance=null |
| 8 | lock; construct Pool: allocate object, then assign fields | · | instance=null obj=allocated, fields pending |
| 9 | instance = obj (store becomes visible before the field stores) | · | instance=poolC obj=fields pending |
| 10 | · | fast path: read instance -> non-null, skip the lock, use it | instance=poolC ✕ T2 holds a reference to a pool whose maxConnections is still 0 and whose socket list is null. One pool, still broken. |
The primitives that already solve it
Every mainstream platform ships something for this, and every one of them handles both halves — mutual exclusion during construction and correct publication afterwards. Use them. Hand-rolled double-checked locking is the single most reliably wrong pattern in this domain, and it has been rewritten wrongly in production code for thirty years.
The mechanics differ meaningfully. C++ gives you std::call_once and, since C++11, the guarantee that a function-local static is initialized exactly once with correct publication — which makes the Meyers singleton the right default there. Java has the class initializer and the holder idiom. Python has functools.lru_cache and module import, both of which are serialized by an import lock. JavaScript has module evaluation, which happens once per module per realm and is not concurrent at all on one event loop.
What none of them fix is *what the initializer does*. If construction can fail, a once-primitive may cache the failure or leave the state uninitialized for a retry, and those are very different behaviours to depend on. If construction blocks — opening sockets, reading a file — every other thread waits on it, so a lazy singleton over a slow dependency turns first-request latency into a stampede. And if construction of A calls something that initializes B which initializes A, the once-primitive deadlocks on itself, which is a genuinely confusing stack trace.
1// Function-local static: initialized exactly once, with a2// happens-before edge to every later read. Guaranteed since C++11.3Pool& pool() {4 static Pool instance{20}; // thread-safe initialization5 return instance;6}7 8// Explicit form when the initializer needs arguments:9std::once_flag flag;10std::unique_ptr<Pool> p;11void init() { std::call_once(flag, []{ p = std::make_unique<Pool>(20); }); }The compiler emits a guard variable and the required barriers. Hand-written double-checked locking without atomics is undefined behaviour here, not merely risky. If the initializer throws, the static is not considered initialized and the next call retries.
1// Module evaluation runs once per module per realm.2export const pool = new Pool(20)3 4// Lazy + async: memoize the promise, not the value, so concurrent5// callers await the same in-flight construction instead of racing.6let poolPromise7export function getPool() {8 poolPromise ??= createPool(20).catch((e) => { poolPromise = undefined; throw e })9 return poolPromise10}One event loop means no preemption between the read and the assignment, so the two-object race cannot happen synchronously. It absolutely can happen across an await — which is why the memoized value must be the promise, and why the catch clears it rather than caching a failure forever.
1let instance: Pool | undefined2let building: Promise<Pool> | undefined3 4export function getPool(): Promise<Pool> {5 if (instance) return Promise.resolve(instance)6 building ??= createPool(20).then((p) => { instance = p; building = undefined; return p })7 return building8}Same runtime semantics as JavaScript; the types only document the three states (absent, in flight, ready). The in-flight state is the one hand-rolled versions forget, and it is exactly where the duplicate construction happens.
1import functools, threading2 3@functools.lru_cache(maxsize=1) # not a lock: two threads can both run it4def pool() -> Pool:5 return Pool(20)6 7_lock = threading.Lock()8_instance: Pool | None = None9def pool_locked() -> Pool:10 global _instance11 if _instance is None: # fast path is safe here for a different reason12 with _lock:13 if _instance is None:14 _instance = Pool(20)15 return _instancelru_cache is not a synchronization primitive — under threads two callers can both execute the function and one result is discarded. The locked form is correct in CPython because reference assignment happens at bytecode granularity and the interpreter provides the ordering; the identical code in C++ or Java without atomics is not.
- C++ needs an explicit publication guarantee; the language provides one for function-local statics and call_once, and nothing for a hand-rolled null check.
- JavaScript and TypeScript cannot race synchronously on one event loop but race freely across await — so the thing to memoize is the in-flight promise.
- CPython's reference assignment gives publication for free, which makes double-checked locking accidentally correct there and a dangerous habit to carry to another language.
- None of the four decides what happens when the initializer fails; that is your policy, and caching a failed singleton forever is a common production bug.
Do not initialize lazily unless laziness is the requirement
The whole problem is optional. Constructing the pool at startup, before any request thread exists, removes every schedule in this lesson — there is no concurrency during initialization because there is nothing else running yet. Eager initialization also moves failure to a place where it is useful: a bad database URL crashes the process at boot instead of failing the first request twenty minutes after the deploy looked green.
Lazy initialization earns its keep when construction is expensive and often unnecessary — an optional client for a feature most deployments do not enable — or when the configuration it needs genuinely is not available at startup. Those are real cases. "It felt tidier" is not, and it is the reason most of these races exist.
Where laziness is required, prefer a once-primitive with a documented failure policy, keep the initializer free of anything that could re-enter the same initializer, and measure the first-call latency: everyone who arrives during construction is waiting, so a slow lazy init under a cold start is a thundering herd on the first request after every deploy. See Thundering Herd and Single-Flight Coalescing for the coalescing pattern that generalizes this.
1type State =2 | { tag: 'absent' }3 | { tag: 'building'; promise: Promise<Pool> }4 | { tag: 'ready'; pool: Pool }5 6let state: State = { tag: 'absent' }7 8export function getPool(): Promise<Pool> {9 if (state.tag === 'ready') return Promise.resolve(state.pool)10 if (state.tag === 'building') return state.promise // coalesce: one construction11 12 const promise = createPool(20).then(13 (pool) => { state = { tag: 'ready', pool }; return pool },14 (err) => { state = { tag: 'absent' }; throw err }, // policy: failures are retryable15 )16 state = { tag: 'building', promise }17 return promise18}Key points
- "If missing, create it" is a check-then-act. Two threads inside the window construct two objects and one assignment survives.
- Duplicate construction is rarely harmless: doubled connection pools, split caches and orphaned resources all present as something other than an initialization bug.
- The worse failure is publishing the reference before the object is fully built — a visibility problem that a lock on the construction path does not solve.
- Use the platform's once-primitive. Hand-rolled double-checked locking is the most reliably wrong pattern in this domain.
- Eager initialization at startup removes every one of these schedules and moves failure to boot, where it is useful. Be lazy only when laziness is the requirement.
The loop, answered
Every field is required, which is why no lesson here can recommend concurrency without naming the interleaving that breaks it, the complexity it adds, and the simpler thing to consider first.
- • A caller checks whether the instance exists. This read is the first half of a check-then-act and is not atomic with what follows.
- • If absent, the caller constructs the object — allocating it and then assigning its fields, in that order.
- • The caller stores the reference into the shared slot, making the object reachable by everyone.
- • A once-primitive wraps steps two and three: it serializes construction so exactly one runs, and it inserts the ordering that makes the constructed fields visible before the reference is.
- • Later callers take a fast path that reads the slot and returns — correct only if that read is ordered against the constructing thread's writes.
- • Double construction: T1 reads instance (null); T2 reads instance (null); T1 constructs and assigns; T2 constructs and assigns — two pools built, 40 sockets open, one pool unreachable.
- • Benign-looking loss: T1 assigns poolA, T2 assigns poolB, T1 continues using its local reference to poolA while every later caller gets poolB — two live pools with different callers, indefinitely.
- • Unsafe publication: T1 allocates the object, the reference store becomes visible before the field stores, T2 reads a non-null instance and dereferences a pool with a null socket list.
- • Correct with once: T1 enters the once-region and constructs; T2 calls in and blocks inside the primitive; T1 finishes and publishes; T2 returns the same fully constructed instance.
- • Failure caching: T1's initializer throws because the database is briefly unreachable; the once-primitive records "already run"; every subsequent call returns the broken or absent instance until the process restarts.
- • Re-entrant deadlock: the initializer for A calls code that initializes B, whose initializer calls getA(); the once-primitive sees the flag as in-progress on the same thread and either deadlocks or returns a half-built A.
- • Async duplication: two callers both await createPool() because the code memoized the resolved value instead of the in-flight promise — no threads involved, two pools anyway.
- • A once-primitive guarantees the initializer body runs at most once across all threads, and that its writes are visible to every caller that returns from it.
- • It guarantees callers arriving during construction block until it completes rather than proceeding with a partial object.
- • It does NOT guarantee the initializer is fast, non-blocking, or free of I/O — every waiter pays its full duration.
- • It does NOT define a failure policy. Whether a throwing initializer is retried or permanently recorded as run is platform-specific and load-bearing.
- • It does NOT detect re-entrancy in general. An initializer that transitively calls itself may deadlock rather than produce a helpful error.
- • A plain null check plus a lock does NOT guarantee safe publication for a fast path that reads without the lock — the lock orders the writers, not the unsynchronized reader.
- • Memoizing the resolved value in async code does NOT prevent duplicate construction; only memoizing the in-flight promise does.
- • Contention exists only during the very first calls, but every caller that arrives in that window waits for the full construction time.
- • A slow initializer converts a cold start into a stampede: the first N requests after a deploy all block on one socket-opening operation.
- • After initialization the cost should be one read of an already-cached line — if your fast path takes a lock every call, you have made every request pay for a race that lasted 40 microseconds once.
- • Nested initializers serialize their dependency chain, so a deep graph of lazy singletons has a startup critical path nobody planned.
- • Duplicate construction, leaking whatever the loser allocated — sockets, threads, file handles, background timers that keep running.
- • Unsafe publication of a partially constructed object, producing null fields and impossible states far from the initialization code.
- • Cached failure: a transient error during initialization permanently recorded, requiring a restart to clear.
- • Deadlock or half-initialized return from a re-entrant initializer.
- • Thundering herd on first use after every deploy, when initialization is slow and traffic is immediate.
- • Resource-limit breach downstream — the database refusing connections because two pools each opened their configured maximum.
- • Async duplicate construction with no threads present at all, from memoizing the value rather than the promise.
- • Lazy initialization helps when construction is genuinely expensive and frequently unnecessary — an optional integration most tenants never use.
- • It helps when the configuration needed to construct the object does not exist at process start.
- • A once-primitive helps whenever laziness is required, because it handles exclusion and publication together and you will not.
- • Memoizing the in-flight promise helps in async code generally, not just for singletons — it is Single-Flight Coalescing applied to construction.
- • Lazy initialization hurts when it moves a startup failure into the request path, where it fails a user instead of a deploy.
- • It hurts when construction is slow and traffic is immediate, turning every cold start into a stampede.
- • It hurts in a deep dependency graph, where lazy initializers call other lazy initializers and the ordering becomes emergent and re-entrant.
- • Double-checked locking hurts everywhere it is written by hand, because the version that is correct in one language is undefined behaviour in another.
- • Count constructions. A counter incremented in the initializer that ever exceeds one is the entire bug, proven, in one number.
- • Downstream resource counts against configuration: connections observed at the database versus the pool maximum you set.
- • First-call latency and the number of callers that blocked on it, per process lifetime — the stampede signal.
- • Initializer failure count and whether subsequent calls recovered, which tells you what your failure policy actually is rather than what you assumed.
- • Stack depth or a re-entrancy assertion inside the initializer, which turns a mysterious deadlock into an immediate, readable error.
- • A correct lazy initializer has three states, not two: absent, in flight, and ready. Most bugs come from modelling only two.
- • The failure policy must be chosen explicitly and documented, because both retry-forever and cache-the-failure are defensible and produce opposite incidents.
- • Re-entrancy has to be reasoned about across the whole dependency graph, and nothing in the type system flags it.
- • The fast path's memory ordering is load-bearing and invisible; getting it wrong produces a bug that never reproduces on the developer's architecture.
- • Eager initialization has none of this complexity, which is the strongest argument available for it.
- • Construct at startup, before any concurrency exists. No schedule in this lesson can occur, and failure moves to boot where it belongs.
- • Dependency injection: build the object once in a composition root and pass it in, which removes both the global and the race.
- • Module-level initialization, where the runtime already guarantees once-per-module semantics.
- • Single-flight coalescing on the in-flight promise, when initialization is asynchronous. See Single-Flight Coalescing.
- • A supervised background initializer that publishes when ready and lets callers fail fast until then, when blocking the first requests is unacceptable.
Single-flight: N callers, one call
without 64 callers → 64 downstream calls latency 192 ms (queued behind each other) with 64 callers → 1 downstream call latency 60 ms (everyone waits for the leader) failure one attempt, 64 disappointed callers — the blast radius of a single bad call is now N retry the followers cannot retry independently; they only ever saw the leader's outcome
Publish a value, then a flag — which edge makes it visible?
Writer Reader
data = 42; while (ready == 0) { }
ready = 1; use(data);| # | Writer | Reader | State |
|---|---|---|---|
| 1 | data ← 42 | · | data=42 ready=0 reader sees=— |
| 2 | ready ← 1 (plain store) | · | data=42 ready=1 reader sees=— |
| 3 | · | read ready → 1 | data=42 ready=1 reader sees=— |
| 4 | · | read data → 0 | data=42 ready=1 reader sees=0 ✕ the reader observed ready = 1 and data = 0 — it saw the flag that announces the write without seeing the write |
What people believe, and what is true
Double-checked locking is fine now that everyone has a modern CPU.
It is correct only with the right language-level ordering — an atomic or volatile with acquire/release semantics. Without it, it is undefined behaviour in C++ and broken on weakly ordered hardware, and the version that works on x86-64 will fail on ARM.
A single-threaded runtime cannot have an initialization race.
It cannot have one *synchronously*. Two callers can both pass the null check before either awaits construction, producing two objects with no threads anywhere. Memoize the promise, not the value.
Duplicate construction is harmless because one copy gets garbage collected.
The loser holds sockets, threads and timers that the collector does not urgently reclaim and may never run finalizers for. What you observe is a connection limit at the database, not a memory issue.
Go deeper
Overview
Two threads both find the object missing and both create it. Use the language's once-primitive, or better, create the object at startup so there is no concurrency to race with.
Practical
Model three states: absent, in flight, ready. Decide what a failed initializer does. Do not put I/O in an initializer that request threads block on unless you have measured the first-call stampede.
Advanced
Watch for re-entrancy across the lazy dependency graph, and count constructions in production — one counter turns an unreproducible bug into a proven one.
Internals
The hard half is publication, not exclusion. Without a release store on the publishing side and an acquire load on the reading side, a fast path can observe a non-null reference whose target fields are not yet visible. This is exactly why double-checked locking needs an atomic, and why the language guarantee for function-local statics is worth using.