Skip to content

Immutability and Safe Publication

Making shared state impossible to corrupt by making it impossible to change — value objects, copy-on-write, and why partly-constructed objects escape.

Advanced19 minUpdated 8 August 2026

The cheapest concurrency strategy is to have nothing to synchronise. An object that cannot change after construction can be shared by any number of threads with no locks, no visibility concerns and no possibility of one thread observing another's half-finished update.

This is a pattern in the sense that matters — a named, reusable structural decision — and it is the one that removes the largest number of bugs per unit of effort. The mechanisms it replaces are covered under concurrency and thread safety; this topic is about the structure that makes most of them unnecessary.

What immutability actually guarantees

Conceptual diagramMutable shared objectThread A readsfields 1 and 2Thread B writesfields 1 and 2A may see field 1 oldand field 2 new —a state that never existedImmutable objectThread A readsThread B readsEvery reader sees oneconsistent, complete state
Diagram, as text
  • Mutable shared object leads to Thread A reads fields 1 and 2
  • Mutable shared object leads to Thread B writes fields 1 and 2
  • Thread A reads fields 1 and 2 leads to A may see field 1 old and field 2 new — a state that never existed
  • Immutable object leads to Thread A reads
  • Immutable object leads to Thread B reads
  • Thread A reads leads to Every reader sees one consistent, complete state
  • Thread B reads leads to Every reader sees one consistent, complete state

The guarantee is stronger than "no lost updates". A reader of a mutable object with several fields can observe a combination that never existed — the old currency with the new amount — because nothing makes multi-field updates atomic. Immutability removes that by making the whole object the unit of change: you replace it, you never edit it.

class Money {
  constructor(amountInCents, currency) {
    this.amountInCents = amountInCents;
    this.currency = currency;
    Object.freeze(this);
  }
  plus(other) {
    if (other.currency !== this.currency) throw new CurrencyMismatch();
    return new Money(this.amountInCents + other.amountInCents, this.currency);
  }
}

plus returns a new value. Two threads calling it on the same Money cannot interfere, because neither writes anything.

Deep immutability, not shallow

Freezing an object whose fields are mutable collections gives you a frozen shell around changeable contents — the same one-level-too-shallow error described under Prototype, with concurrency consequences.

class Order {
  constructor(id, lines) {
    this.id = id;
    this.lines = lines;          // still a mutable array
    Object.freeze(this);         // freezes the reference, not the array
  }
}

Any holder can call order.lines.push(...). Three rules make it real:

  • Copy on the way in. The constructor takes a copy, so the caller's array is not your state.
  • Freeze what you hold, recursively for nested structures.
  • Copy or freeze on the way out, so a getter cannot hand out a mutable interior.

The cost is allocation, and it is almost always the right trade. Where it is not, a persistent data structure — one that shares unchanged parts between versions — gives immutability with sub-linear copying.

Safe publication: the subtle half

An immutable object is safe once other threads can see it correctly, and getting it there is a separate problem.

A reference published unsafely can be visible before the object's fields are. Another thread may see a non-null reference to an object whose fields still hold their default values, because the compiler and processor are permitted to reorder the write of the reference ahead of the writes of the fields. The object is correct; the publication was not.

Languages address this with a final-field guarantee: fields assigned in the constructor and never reassigned are guaranteed visible to any thread that sees the object, without synchronisation. This is why "immutable" in a concurrency discussion means specifically all fields final and assigned once, not merely "we do not call setters".

The escape that breaks it: letting this leave the constructor before it finishes — registering a listener, starting a thread, passing itself to a collaborator. Another thread then holds a reference to a partly-constructed object and the guarantee does not apply.

Copy-on-write

The practical middle ground when reads vastly outnumber writes: hold an immutable snapshot, and on a change build a new one and swap the reference atomically.

let routes = Object.freeze(new Map(initial));      // readers use this directly

function addRoute(path, handler) {
  const next = new Map(routes);
  next.set(path, handler);
  routes = Object.freeze(next);                     // one atomic reference swap
}

Readers need no synchronisation at all and never block. Writers pay a full copy, and a reader that captured the reference before the swap continues using the old snapshot — which is usually exactly what you want for configuration, routing tables and listener lists.

Where it fails: frequent writes. Copying a large structure per write is quadratic in a hot loop, and two concurrent writers can lose an update unless the swap uses a compare-and-set. Copy-on-write is for read-heavy, write-rare state.

Where mutability still belongs

Local, unshared state. Building a list inside a function and returning it frozen is fine and fast. The rule is about shared state.

Large structures with heavy churn. A simulation grid or an image buffer updated per frame should not allocate a copy per change; confine it to one owner instead.

Identity that must persist. An entity whose state changes over time — an order moving through its lifecycle — is naturally mutable, though the values it holds should be immutable, and its state transitions are better modelled with the State pattern than with field assignment.

Check your understanding

Common mistakes

Shallow freezing. A frozen object holding a mutable array is not immutable.

Getters returning interior collections. The encapsulation leaks and callers mutate your state.

Not copying constructor arguments. The caller keeps a reference and can change your state after construction.

Letting this escape the constructor. Other threads read uninitialised fields.

Copy-on-write under write pressure. Copying per write is quadratic in a loop.

A non-atomic copy-on-write swap. Two writers copy the same base and one update is lost.

Treating immutability as a concurrency-only concern. Most of the bugs it prevents are single-threaded aliasing failures.

Making an entity immutable. Things with identity and a lifecycle are naturally mutable; make the values they hold immutable instead.

Practice

A configuration object is loaded at start-up, read by every request, and hot-reloaded when a file changes. Reads must never block. Design it, and state exactly what a request sees during a reload.

Write your own answer first, on paper or in a note. Then compare.

Show a hint

Decide what a request that started before the reload should see, and be precise about mid-request consistency.

Show a strong answer

The design: an immutable configuration snapshot behind a single atomic reference — copy-on-write.

  • The config object is deeply immutable, built once by the loader, with nested maps and lists frozen and copied on the way in.
  • One atomic reference holds the current snapshot. Readers read the reference and use whatever they get. No lock, no blocking, ever.
  • Reload builds a completely new object and swaps the reference in a single write. There is no intermediate state in which the config is partly updated, because the config is never updated — it is replaced.

Exactly what a request sees, which is the part that must be specified rather than left to chance:

  • A request that reads the reference once at its start sees one consistent snapshot for its whole duration, even if a reload lands mid-request. That is the behaviour I want, and it does not happen by accident — it requires the request to capture the reference once and pass it down, rather than reading the global at each use.
  • A request that reads the global reference at several points may see the old config at one and the new at another. This is the subtle failure: each individual read is safe, and the request as a whole is incoherent — checking a feature flag as off early and as on later. Capturing once per request is the fix, and it should be enforced structurally by passing the snapshot rather than exposing a getter.
  • In-flight requests never see a torn config, because there is no partial state to see.

Reload details:

  • Validate the new config completely before swapping. A malformed file must leave the running config in place, log loudly, and not take the service down. Swapping first and validating after is how a bad file becomes an outage.
  • The swap is a plain reference write — a single write of one reference, which is atomic. No compare-and-set is needed because there is exactly one writer, the reload path. If reloads could be concurrent, I would serialise them rather than reach for CAS, since reloads are rare.
  • Log the version before and after, so an incident can establish which config was live.

What I would not do: mutate the existing config object under a read-write lock. It would make every request take a lock on a hot path to protect against an event that happens twice a week, and it would still allow a request to observe a partly-updated config unless it held the lock for its whole duration — which would mean reloads block requests.

The general shape worth remembering: immutable snapshot, atomic swap, capture once per unit of work. The third part is the one teams forget, and it is where the incoherence comes from.

A code review flags a class as 'immutable' because it has no setters. What would you check before agreeing, and what would you say if the class also starts a background refresh thread in its constructor?

Write your own answer first, on paper or in a note. Then compare.

Show a hint

Absence of setters is the weakest of the requirements.

Show a strong answer

No setters is necessary and nowhere near sufficient. What I would check, in order of how often each one is the actual problem:

  • Are the fields assigned once and never reassigned? A field mutated by an internal method is mutable regardless of the class's public surface — and it forfeits the visibility guarantee that makes safe sharing work.
  • Are the field types immutable? A List or Map field with no setter is fully mutable through the reference. This is the most common failure and the one that looks most like immutability.
  • Are constructor arguments copied? If the caller passes a list and keeps a reference, they can modify the object's state afterwards.
  • Do getters return interiors? A getter handing out the internal list makes the class mutable from outside, which is the same problem in the other direction.
  • Is anything lazily computed and cached? A memoised field is a write after construction. It can be made safe, but it needs deliberate handling rather than being assumed harmless.

On the background thread started in the constructor: that is a genuine defect and I would block the change on it.

  • It publishes this before construction completes. The thread holds a reference to a half-built object and may read fields that are plainly assigned in the source and still hold defaults. That failure is exceptionally hard to diagnose, because the code being read makes it look impossible — and it will not reproduce reliably, so it arrives as a rare production anomaly.
  • A refresh thread implies mutation. If it refreshes something, something changes, so the class is not immutable in the sense that matters. Either the refreshed state is a separate mutable holder, or the class produces new instances and something else swaps them.
  • It also breaks the class in ordinary ways: it cannot be constructed in a test without starting a thread, it has no lifecycle to stop the thread, and every instance leaks one.

What I would propose instead:

  • Make the class a pure immutable snapshot with no thread and no refresh.
  • Put the refreshing in a separate holder that constructs new snapshots and swaps an atomic reference — the copy-on-write structure from the previous question.
  • Construct fully, then publish, via a static factory that builds the object and only afterwards hands it to anything else.

How I would phrase the review: not "this is not immutable" — a definitional argument — but "a thread started here can read this before the constructor finishes, and will occasionally see empty fields". That is a concrete failure with a concrete fix, and it is much harder to disagree with.

Key takeaways

  • Immutability removes the need to synchronise by removing the thing that changes — the cheapest concurrency strategy available.
  • Its strongest guarantee is that no reader can observe a combination of fields that never existed.
  • Deep, not shallow: copy constructor arguments, freeze nested structures, and never hand out interior collections.
  • Safe publication is a separate problem. Fields assigned once in a constructor are guaranteed visible — unless this escapes before construction finishes.
  • Never start a thread or register a callback from a constructor. Construct fully, then publish.
  • Copy-on-write gives lock-free reads for read-heavy, write-rare state; it fails under write pressure and needs an atomic swap.
  • Capture a snapshot once per unit of work. Re-reading a shared reference mid-request gives a coherent read each time and an incoherent request.
  • Mutability still belongs in local unshared state, high-churn buffers, and entities with a lifecycle — whose values should still be immutable.

Further reading

  • Brian Goetz and others, Java Concurrency in Practice (Addison-Wesley, 2006) — safe publication, final-field semantics and copy-on-write.
  • Joshua Bloch, Effective Java, third edition (Addison-Wesley, 2018) — minimise mutability, and make defensive copies.
  • Jeremy Manson, William Pugh and Sarita Adve, "The Java Memory Model" (POPL, 2005).
  • Chris Okasaki, Purely Functional Data Structures (Cambridge University Press, 1998) — persistent structures with sub-linear copying.
  • Eric Evans, Domain-Driven Design (Addison-Wesley, 2003) — value objects versus entities.

Check yourself

3 questions in this lesson. Answer each once to complete it.

Practice this

Next lessonActors and Message Passing
Lessons in this track