The question this answers
If the data can never change after construction, what is left to synchronize?
Eight request handlers reading a 40,000-entry routing table while a background reload replaces it from a config file every 30 seconds.
The routing table and the reference that points at it. The table contents are shared by every handler; after construction they are never written again. The only memory anyone ever writes is the one reference slot.
Every handler resolves its whole request against one complete table version — never a table holding half the new routes and half the old ones, and never an entry whose key was written but whose value was not.
Synchronization exists to preserve this sentence. If a schedule can make it false, the code is wrong no matter which primitive it uses.
Mutation is the thing you were synchronizing
Take the reload that edits the table in place: clear the map, then insert 40,000 entries from the new config. Every one of those 40,001 writes is a moment at which a reader can observe a table that never existed as a configuration — no routes at all, or the first 12,000 routes of the new file with none of the rest. The lock you are about to reach for exists to hide those moments.
Now build the new table in a local variable nobody else can see, and publish it with a single reference assignment. There is no partially-updated state to hide, because the object a reader can reach is either the old complete table or the new complete table. The critical section did not get smaller; it stopped existing. That is the move, and it is available far more often than engineers reach for it.
This is the reasoning layer on top of Shared Mutable State: the question is never "which lock", it is "why is this mutable at all". Where the data genuinely must change, you are back to Finding the Critical Section — but a surprising share of production shared state is read a million times and written twice a day.
| # | Reload thread | Handler (request 8812) | State |
|---|---|---|---|
| 1 | table.clear() | · | entries=0 version=mid-write |
| 2 | · | lookup("/api/orders") | entries=0 result=miss ✕ The handler observed a table that was never a valid configuration — it 404s a route that exists in both the old and the new file. |
| 3 | insert 12,000 entries | · | entries=12000 version=mid-write |
| 4 | · | lookup("/api/users") | entries=12000 result=hit (new value) |
| 5 | insert remaining 28,000 entries | · | entries=40000 version=new |
What it costs, and where it stops being free
Immutability is not free; it is *differently* priced. You pay allocation and copy on the write path to buy zero coordination on the read path. When reads outnumber writes by four orders of magnitude, that trade is not close. When a hot loop rewrites one field of a large object a million times a second, it is a disaster, and the honest answer is a mutex or a mutable local.
The middle ground is structural sharing: a persistent map that reuses every untouched subtree and allocates only the path from root to the changed key. A write costs O(log n) small allocations instead of O(n), while every previously handed-out reference stays valid and complete forever. That is the same idea as Copy-on-Write as a Concurrency Strategy, applied per node rather than per structure.
The other cost is allocation pressure, which is a real budget and not a rounding error. Rebuilding a 40k-entry table every 30 seconds is nothing. Rebuilding it per request is a garbage-collection incident waiting for peak traffic.
| Strategy | Read cost | Write cost | Memory | Use when |
|---|---|---|---|---|
| Rebuild whole structure on write | Zero coordination, direct access | O(n) copy + O(n) allocation | Two full copies briefly live | Writes are rare and bounded — config, routing tables, feature flags |
| Persistent structure (structural sharing) | Zero coordination, O(log n) lookup | O(log n) nodes allocated | Old versions retained only while referenced | Writes are frequent but the structure is large |
| Immutable value objects (copy-on-change) | Zero coordination | One small allocation per change | Proportional to churn | Small records passed between tasks — the default for message payloads |
| Freeze after construction | Zero coordination | N/A — the object never changes | One copy | Anything built once at startup and read forever |
"Immutable" means four different things in four languages
This is where the strategy leaks. Every mainstream language has a keyword that *looks* like immutability and none of them means "this object graph cannot change and is therefore safe to share". C++ const is a promise about one reference, not about the object. JavaScript Object.freeze is one level deep. TypeScript readonly is erased before the code ever runs. Python frozen=True blocks attribute assignment and nothing else.
The practical rule: immutability is a property of the whole reachable graph, and every language makes you enforce it by construction and convention rather than by keyword. A frozen object holding a mutable array is a mutable object with extra confidence.
The second leak is publication. Making the table immutable guarantees its contents are stable; it does not guarantee another thread *sees the new reference*. That is a visibility question answered by Safe Publication: Handing Over a Finished Object and Happens-Before: The Edge That Makes a Write Visible, and it is why a plain non-atomic pointer swap is not automatically enough in C++.
1// const is a promise about this reference, not about the object.2std::atomic<std::shared_ptr<const Table>> current;3 4void reload() {5 auto next = std::make_shared<const Table>(load_from_disk());6 current.store(next, std::memory_order_release); // publication edge7}8const Table& snapshot() { return *current.load(std::memory_order_acquire); }const forbids writing *through this reference*. Sharing safely also needs an atomic handoff of the pointer — a plain shared_ptr assignment from two threads is a data race, and undefined behaviour, not merely a lost update.
1const table = Object.freeze({ routes: ['/a', '/b'] })2table.routes.push('/c') // succeeds — freeze is one level deep3Object.freeze(table.routes)4// Publication itself is free: a single-threaded event loop cannot5// observe a half-assigned reference. Workers get a structured clone.Object.freeze is shallow and throws only in strict mode. On the main thread publication is trivially safe because there is no preemption between statements; across workers you are copying, not sharing, unless it is a SharedArrayBuffer.
1type Table = { readonly routes: readonly string[] }2declare const t: Table3// t.routes.push('/c') // compile error4// ...and at runtime this is a plain mutable array.5const frozen: Table = Object.freeze({ routes: Object.freeze(['/a']) })readonly is a compile-time check that is erased entirely. It stops your code from mutating; it stops nothing that crosses a boundary — JSON.parse output, a library callback, anything typed as any.
1from dataclasses import dataclass, field2@dataclass(frozen=True)3class Table:4 routes: tuple[str, ...] = () # tuple, not list5 6t = Table(routes=('/a', '/b'))7# t.routes = () -> FrozenInstanceError8# but Table(routes=[]) would hand you a mutable list in a "frozen" objectfrozen=True overrides __setattr__ and does nothing about what the fields contain. Use tuple and frozenset. Reference assignment is atomic at the CPython bytecode level, which makes publication easy here and unportable as a habit.
- C++ needs an explicit atomic handoff for the reference; the other three get it from the runtime.
- JavaScript and Python freeze one level; C++ const and TypeScript readonly are type-system claims, not runtime barriers.
- TypeScript readonly disappears at runtime — it protects your source, not your process.
- Only C++ makes unsynchronized publication undefined behaviour rather than merely wrong.
Key points
- Synchronization exists to hide the moments when data is partially updated. Data that is never updated has no such moments.
- Immutability turns a read-write coordination problem into a publication problem: one reference swap instead of a critical section around every read.
- It costs allocation and copying on the write path — which is why it wins overwhelmingly for read-heavy state and loses badly for hot mutable loops.
- Structural sharing (persistent data structures) is the middle setting: O(log n) writes with every old version still valid.
- No language keyword gives you deep immutability. const, readonly, freeze and frozen=True each guard one level or one reference.
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.
- • Construct the new value in a local that no other task can reach.
- • Fully initialize it — every field, every entry — before any reference to it escapes.
- • Publish it with a single reference write, with whatever ordering the language requires for another thread to see it.
- • Readers take the reference once, at the start of their unit of work, and use that snapshot for the whole unit.
- • The old version stays alive and correct until the last reader drops it; reclamation is the runtime's problem (or a shared_ptr refcount).
- • Mutable, in place: R clears the table; H looks up /api/orders and misses; R inserts 40,000 entries — H returned 404 for a route that exists in both configurations.
- • Immutable, published: R builds table v2 privately; H reads ref (v1); R stores ref = v2; H reads ref again (v2) — H saw v1 then v2, both complete. No interleaving produces a partial table.
- • Immutable, re-read mid-request: H reads ref (v1) for authz; R publishes v2; H reads ref (v2) for routing — internally consistent per read, inconsistent per request. Snapshot once per unit of work, not once per lookup.
- • Unsafe publication: R writes the fields of the new table, then writes the reference; the compiler or CPU reorders the reference write ahead of a field write; H follows the new reference and reads an uninitialized field. Immutability of contents does not imply ordered publication of the pointer.
- • Guarantees: any number of readers may read an immutable object concurrently, with no lock, no ordering rule and no possibility of observing a partial value.
- • Guarantees: a reference held by a reader stays valid and complete for as long as it is held, regardless of how many times the publisher swaps.
- • Does NOT guarantee that another thread sees the *new* reference at any particular time — that is visibility, and needs a release/acquire pair or the runtime's equivalent.
- • Does NOT guarantee the object graph is immutable. A frozen object holding a mutable list is mutable shared state wearing a badge.
- • Does NOT guarantee a consistent view across multiple reads. Two reads of the reference can land on two versions unless you snapshot once.
- • Does NOT eliminate coordination between *writers*. Two reload threads still need to agree on which version wins — see Optimistic Concurrency Control.
- • Reads contend on nothing at all. This is the entire point, and it is the only common design where adding readers has no coordination cost whatsoever.
- • Writers contend on the single reference slot — usually one atomic store, which is cheap but still a shared cache line if writes are frequent.
- • The real contention moves to the allocator and the garbage collector: high-churn immutability turns lock contention into allocation contention, which is a better problem but not a free one.
- • Memory bandwidth becomes the ceiling for large structures rebuilt often, because every rebuild is a full traversal plus a full write.
- • Race condition on an application invariant when the structure is edited in place: a reader sees a configuration that never existed.
- • Unsafe publication — the reference becomes visible before the fields it points at, so a reader dereferences a half-built object. In C++ this is undefined behaviour, not a stale read.
- • Shallow-freeze escape: an "immutable" record hands out a mutable array and one caller sorts it in place, mutating every other holder's view.
- • Version skew inside one request when the reference is re-read instead of snapshotted.
- • Allocation-driven latency: GC pauses or allocator contention replacing lock waits, showing up as p99 spikes that no lock metric explains.
- • Read-heavy shared state with rare bulk updates: routing tables, feature flags, pricing rules, compiled configuration, loaded models.
- • Anything handed across a task or thread boundary — an immutable payload cannot be corrupted by the sender after it is sent, which is what makes Message Passing safe.
- • Systems where the debugging cost of a rare interleaving is high: an immutable value cannot be the culprit, which shrinks the search space enormously.
- • Snapshot semantics for long operations: a report that runs for 90 seconds against a stable version rather than a shifting one.
- • Hot mutable loops — accumulators, buffers, per-frame state. Allocating a new object per iteration is strictly worse than a mutable local or a mutex.
- • Very large structures with high write rates, where the copy cost and memory ceiling dominate everything the design saved.
- • Latency-sensitive paths on a garbage-collected runtime where the churn moves the cost into unpredictable pauses.
- • When the state is genuinely a shared mutable ledger — a running balance that many writers update — immutability alone does not solve the writer coordination.
- • Lock wait time on the structure's lock before and after: the honest evidence that coordination disappeared rather than moved.
- • Allocation rate (bytes/sec) and GC pause distribution after conversion — this is where the cost reappears.
- • Peak resident memory during a publish, which briefly holds both versions plus any retained old versions still referenced by in-flight requests.
- • Count of requests that observed more than one version, if you tag the snapshot with a version id — a direct measurement of the version-skew failure mode.
- • A build-then-publish path is more code than an in-place edit, and it must be genuinely private until published — one escaped reference undoes the whole design.
- • Readers must be disciplined about snapshotting once per unit of work; that convention is invisible in the type system and easy to break during a refactor.
- • You now reason about versions and their lifetimes: which versions are still referenced, how long the old one stays alive, and whether that is bounded.
- • Persistent data structures add a real dependency and unfamiliar performance characteristics that most teams cannot reason about from first principles.
- • A read-write lock over the mutable structure — simpler, no allocation churn, and correct; it costs reader coordination and can starve writers. See Read/Write Locks, Honestly.
- • A plain mutex, when the structure is small and the read path is short. Genuinely the right answer more often than the immutability enthusiast admits.
- • Copy-on-write with a version pointer, when writes touch a small part of a large structure — see Copy-on-Write as a Concurrency Strategy.
- • Confining the state to one owner and sending messages instead of sharing it at all — see Message Passing and The Actor Model.
Immutability lab
| # | Writer | Reader | State |
|---|---|---|---|
| 1 | account.a -= 10 | · | a=40 b=50 a+b=90 |
| 2 | · | read account.a, account.b | a=40 b=50 a+b=90 ✕ the reader observed a total of 90 — a state no writer ever intended |
| 3 | account.b += 10 | · | a=40 b=60 a+b=100 |
| 4 | · | read account.a, account.b | a=40 b=60 a+b=100 |
What people believe, and what is true
Immutable data is slower because it copies more.
It copies more on the write path and coordinates zero times on the read path. For read-heavy state the removed lock traffic and removed cache-line ping-pong routinely outweigh the copying — and for write-heavy state the misconception is correct, which is why the read/write ratio is the deciding number.
Object.freeze makes an object safe to share between threads.
It is shallow, and in a browser or Node the objects are not shared between workers anyway — they are structured-cloned. The mechanism that would need protecting is not the one freeze protects.
If the object is immutable I do not need any synchronization.
You need none for the *contents*. You still need a happens-before edge for another thread to observe the *reference* — the classic unsafe-publication bug hides exactly here. See Safe Publication: Handing Over a Finished Object.
Go deeper
Overview
Shared data that never changes can be read by any number of tasks at once with no locking, because there is no moment when it is half-updated. Build the new version privately, then swap a single reference.
Practical
Apply it to read-heavy state with rare writes: config, routing, flags, rules. Snapshot the reference once per request and use that snapshot throughout. Watch allocation rate after the change — the cost moves there.
Advanced
Use persistent structures when writes are frequent enough that whole-structure rebuilds hurt, accepting O(log n) node allocation and pointer-chasing in exchange for keeping every old version valid.
Internals
Publication is a memory-model question. The publisher needs a release store and the reader an acquire load (or the language equivalent) so the reader cannot observe the reference before the fields. Without it, a reader can legally see a fully constructed object as partially constructed.