Skip to content

Transactions (STM)

Concurrent code that shares mutable state is hard to get right. The moment two fibers read-modify-write the same data, you risk lost updates, partial writes, and inconsistent reads. Effect’s answer is Software Transactional Memory (STM): you mark a block of state changes as a transaction with Effect.tx, and Effect guarantees that the whole block commits atomically — all of it, or none of it — without you ever writing a lock by hand.

import { Effect, TxRef } from "effect"
// Move money between two accounts. The read, the check, and both writes
// must all happen as a single atomic step — no other fiber can observe a
// state where the money has left one account but not arrived in the other.
const transfer = (
from: TxRef.TxRef<number>,
to: TxRef.TxRef<number>,
amount: number
) =>
// Effect.tx is the transaction boundary: everything inside commits together
Effect.tx(
Effect.gen(function*() {
const balance = yield* TxRef.get(from)
if (balance < amount) {
// Failing aborts the transaction — none of the writes below are kept
return yield* Effect.fail("insufficient funds" as const)
}
yield* TxRef.update(from, (n) => n - amount)
yield* TxRef.update(to, (n) => n + amount)
})
)

Effect’s STM is optimistic. A transaction body runs against a private journal of reads and writes rather than touching the shared values directly. At commit time Effect checks whether any value the transaction read was changed by another fiber in the meantime:

  • If nothing it read has changed, the journal is committed in one atomic step.
  • If there was a conflict, the journal is thrown away and the body re-runs from scratch against fresh values.

A transaction can also wait deliberately. Calling Effect.txRetry suspends the transaction until one of the transactional values it read changes — turning “there isn’t enough data yet” into a clean, declarative blocking primitive instead of a polling loop.

Transactional state

TxRef is the atom of STM — a transactional reference, plus the Effect.tx / Effect.txRetry mechanics that make groups of updates atomic.

Use the Tx* modules whenever more than one fiber touches a piece of state and a single read-modify-write would race. The classic cases are transfers between accounts, inventory and reservation systems, work queues, connection pools, and any “wait until a condition holds” coordination.

For state owned by a single fiber, or where atomicity across multiple values is not a concern, the simpler Ref and SynchronizedRef primitives are a better fit — they have less overhead and no retry semantics. STM earns its keep precisely when you need several values to move together.