Why architecture matters here

The deep problem with locks is that they do not compose. A correctly-synchronized withdraw operation and a correctly-synchronized deposit operation cannot be combined into a correct transfer just by calling one after the other — between the two, another thread can observe the money having left one account but not yet arrived in the other, and the invariant that total balance is conserved is violated. To fix it you must reach inside both operations, expose their locks, and acquire both up front in a globally-agreed order. That breaks encapsulation, and the global lock-ordering discipline is exactly the thing that is impossible to enforce across a large codebase, which is why lock-ordering deadlocks are a perennial class of production incident.

STM composes because atomicity is a property of the combination, not of the individual operations. In ZIO, withdraw returns an STM value — a description of a transaction, not its execution — and so does deposit. You glue them with an ordinary flatMap (or a for-comprehension) into a bigger STM describing the transfer, and only then call commit. Because the whole transfer commits as one unit, there is no window in which another fiber sees the intermediate state. Two operations that were individually correct combine into a whole that is also correct, with no new locking discipline required. That compositionality is the entire reason STM is worth the machinery.

The second architectural payoff is the disappearance of deadlock. A deadlock requires a cycle of threads each holding a resource the next needs; with no locks held across the transaction, there is no such resource to hold, and the cycle cannot form. The runtime may re-run a transaction that lost a validation race, but re-running is progress-preserving in a way that a blocked-forever deadlock is not — the system as a whole always makes forward progress as long as some transaction commits.

None of this is free, and the trade-off is worth naming precisely. Locks are pessimistic: they assume conflict and pay to prevent it up front. STM is optimistic: it assumes conflict is rare, runs full speed, and pays only when a conflict actually materializes — by throwing away the doomed attempt and retrying. That bet is excellent when contention is low and the transactions are short, and it is a bad bet when many fibers hammer the same TRef with long transactions, because then the retries pile up and you burn CPU re-running work that keeps getting invalidated. Understanding which regime you are in is the whole art of using STM well, and it is the thread that runs through every failure mode below.

Advertisement

The architecture: every piece explained

Top row: the vocabulary. A TRef[A] is a transactional reference — a mutable cell holding a value of type A that may only be read or written inside a transaction. It is the STM analogue of a Ref, but its reads and writes are tracked. An STM[E, A] is the transaction itself as a first-class value: a pure description that, when run, may fail with an E or succeed with an A. Critically it is a value, so you build big transactions from small ones with the usual functional combinators before running anything. commit is the boundary: it takes an STM and returns a ZIO effect that, when executed, runs the transaction atomically against the live TRefs.

Middle row: the machinery that makes it atomic. When a transaction runs, the runtime does not mutate the TRefs directly. It keeps a per-transaction read log recording every TRef it read and the version it saw, and a write log holding tentative new values that are visible only inside this transaction. A get checks the write log first (so the transaction sees its own writes) and otherwise reads the live TRef and records the version. check / retry is the blocking primitive: check(condition) that fails, or an explicit retry, tells the runtime this transaction cannot proceed yet and should be suspended until one of the TRefs it read changes.

Bottom rows: commit and re-run. At commit the runtime validates: it re-checks that every TRef in the read log is still at the version the transaction saw. If nothing it observed has changed, the writes are applied atomically under a brief internal guard and the transaction succeeds. If any observed TRef moved — another transaction committed a conflicting change — the whole attempt is discarded and re-run from scratch against the new state. The runtime layer makes this fiber-aware: a retrying transaction parks its fiber (it does not busy-spin) and is woken precisely when a relevant TRef is written, so blocking-until-condition is cheap and composable across arbitrarily many TRefs.

Worth calling out separately are the higher-level STM data structures, because in practice you reach for them far more often than raw TRefs. TMap is a transactional hash map whose conflict granularity is per key: two transactions updating different keys never invalidate each other, so a shared map does not become the serialization bottleneck a single TRef[Map[K, V]] would be. TArray gives the same per-index granularity for indexed collections, TQueue is a transactional queue that composes with other transactional state in one atomic step (enqueue-and-update as a unit), and TSemaphore and TPromise round out the coordination toolkit. Each is built from the same read-log/write-log/validate machinery, but their internal structure is designed so that the version tracking happens at a fine grain — the reason 'store the whole collection in one TRef' is almost always the wrong instinct and 'use the provided transactional structure' is the right one.

ZIO STM — compose atomic transactions over TRefs, retry and roll back automaticallyshared state without locks or deadlocksTRef[A]transactional memory cellSTM[E, A]a description of a transactioncommit -> ZIOrun atomicallyRead logversions seenWrite logtentative updatescheck / retryblock until state changesValidate at commitno TRef changed under us?Apply writes atomicallyelse discard + re-runRuntime — optimistic concurrency, fiber-aware retry, composable across many TRefsstagestageguardcollectcollectconflictrunrun
ZIO STM stages reads and writes in per-transaction logs, validates at commit that no observed TRef changed, and either applies all writes atomically or transparently re-runs — optimistic concurrency with no locks the programmer can deadlock.
Advertisement

End-to-end flow

Trace the canonical transfer between two accounts represented as TRef[Long] balances. Fiber X commits a transfer of 100 from A to B. The transaction reads A (version 7, balance 500), checks it is at least 100, stages a write of A := 400 into the write log, reads B (version 3, balance 200), and stages B := 300. Nothing has executed against the live cells yet — these are all log entries. At commit, the runtime validates: is A still version 7 and B still version 3? Yes. It applies both writes under one guard, bumps their versions to 8 and 4, and the transfer is atomic — no fiber ever saw A debited without B credited.

Now the contended case. Fibers X and Y both try to transfer from A at the same moment. Both read A at version 7 and stage their debits. X commits first: A moves to version 8. Y reaches commit and validation fails — A is no longer version 7 — so Y's entire attempt is thrown away and re-run against the fresh A at version 8, where it now sees the balance X left behind, re-checks the sufficient-funds condition, and commits cleanly at version 9. Y never corrupted anything and never held a lock; it simply paid for the conflict by doing its work twice. This is optimistic concurrency's whole bargain in miniature: cheap when rare, re-run when not.

The retry path shows STM's elegance as a coordination primitive. Suppose a fiber wants to withdraw from A only when the balance is sufficient, and otherwise wait. It writes check(balance >= amount). If the balance is too low, the transaction calls retry: the runtime suspends the fiber and — crucially — remembers that this transaction read A. It does not poll. The fiber sleeps at zero CPU cost until some other transaction commits a write to A, at which point the runtime wakes it and re-runs the transaction against the new balance. If it is now sufficient, it proceeds; if not, it retries again. A blocking queue, a semaphore, a barrier — all fall out of this one primitive composed over TRefs, with no condition variables and no missed-wakeup bugs.

Zoom out to why this stays correct under composition. Because STM values compose with flatMap, a transaction that reads five TRefs and writes three is validated as a single unit: at commit, all five reads must still be current and all three writes land together or none do. There is no partial commit and no lock-ordering to get wrong, because there are no locks — the read log is the record of what the transaction assumed about the world, and validation is the one check that the assumption still holds. That is why a bounded transfer, a multi-account rebalance, and a wait-until-ready barrier can all be written in the same style and combined into larger transactions without any of them knowing about the others, the compositional guarantee that lock-based code can never quite deliver. And because each of these building blocks is an ordinary immutable value, you can pass them around, store them in a list, and assemble the final transaction dynamically at runtime — the atomicity boundary is drawn by commit, not by where the code happened to be written, which is the freedom that makes STM feel like ordinary sequential programming rather than a concurrency framework.