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)
})
)

Without a transaction, a read-modify-write across several values is a sequence of independent steps, and another fiber can interleave between any two of them. The transfer above, written with a plain Ref, can lose money: fiber A reads from, fiber B reads from, both subtract, and one write clobbers the other — a classic lost update. Worse, a third fiber reading both accounts mid-transfer can see money that has left from but not yet arrived in to.

STM removes the interleaving entirely. Inside Effect.tx, the body either observes a single consistent snapshot and commits all its writes at once, or it sees a conflict and runs again — there is never a partially-applied result.

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.

There are no locks to acquire and no deadlocks to design around: contention is resolved by re-running the loser, not by blocking the winner.

Effect.tx defines the boundary. The outermost Effect.tx call is what creates the journal and decides when to commit or roll back; everything that runs inside it — including nested Effect.tx calls — joins that same journal instead of starting its own. Nested transactions do not commit independently, so you can wrap reusable helpers in Effect.tx and still compose them into a larger atomic unit safely.

import { Effect, TxRef } from "effect"
const program = Effect.gen(function*() {
const ref1 = yield* TxRef.make(0)
const ref2 = yield* TxRef.make(0)
// The outer tx is the boundary; the inner tx joins the same journal,
// so both writes commit together (or not at all).
yield* Effect.tx(
Effect.gen(function*() {
yield* TxRef.set(ref1, 10)
yield* Effect.tx(TxRef.set(ref2, 20)) // composes, does not commit alone
})
)
console.log(yield* TxRef.get(ref1)) // => 10
console.log(yield* TxRef.get(ref2)) // => 20
})

A single bare Tx* operation that is not wrapped in Effect.tx still runs atomically — it executes in its own implicit one-operation transaction. You only need an explicit Effect.tx when you want several operations to commit as a unit.

import { Effect, TxRef } from "effect"
const program = Effect.gen(function*() {
const ref = yield* TxRef.make(0)
// No Effect.tx needed: this single update is its own transaction.
yield* TxRef.update(ref, (n) => n + 1)
})

A transaction can also wait deliberately. Effect.txRetry aborts the current transaction and suspends it until one of the transactional values it read changes — then the body re-runs. This turns “there isn’t enough data yet” into a clean, declarative blocking primitive instead of a polling loop, and it parks the fiber cheaply (it is only rescheduled when a relevant value actually moves).

import { Effect, TxRef } from "effect"
const program = Effect.gen(function*() {
const ref = yield* TxRef.make(0)
// A background fiber slowly raises the value.
yield* Effect.forkChild(
Effect.forever(
Effect.tx(TxRef.update(ref, (n) => n + 1)).pipe(Effect.delay("100 millis"))
)
)
// Block until the value reaches 10. Each time `ref` changes, the body
// re-runs; while it is below 10 we suspend instead of busy-waiting.
yield* Effect.tx(
Effect.gen(function*() {
const value = yield* TxRef.get(ref)
if (value < 10) {
return yield* Effect.txRetry
}
})
)
})

Effect.txRetry is the building block under every blocking Tx* operation: TxQueue.take retries while the queue is empty, TxSemaphore.acquire retries while no permit is free, and TxDeferred.await retries until the deferred is set. Because they are all built on the same primitive, they compose: a single Effect.tx can take from a queue and acquire a permit and update a TxRef, and the whole thing blocks until all of its conditions can be satisfied at once.

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, or when you need a fiber to block until a condition becomes true.

The Tx* modules supply the data; these three members of Effect supply the machinery. (The per-module operations are documented on the pages linked above.)

Defines a transaction boundary. The body’s changes to transactional values are all-or-nothing: it commits on success, rolls back on failure, and re-runs on a conflict. Nested Effect.tx calls reuse the outermost journal rather than opening a new boundary.

import { Effect, TxRef } from "effect"
const program = Effect.gen(function*() {
const a = yield* TxRef.make(1)
const b = yield* TxRef.make(1)
// Both updates commit together.
yield* Effect.tx(
Effect.gen(function*() {
yield* TxRef.update(a, (n) => n + 1)
yield* TxRef.update(b, (n) => n * 10)
})
)
console.log(yield* TxRef.get(a), yield* TxRef.get(b)) // => 2 10
})

A failure inside the body rolls back every write made in that transaction:

import { Effect, TxRef } from "effect"
const program = Effect.gen(function*() {
const ref = yield* TxRef.make(0)
yield* Effect.tx(
Effect.gen(function*() {
yield* TxRef.set(ref, 99)
return yield* Effect.fail("abort" as const) // rolls back the set above
})
).pipe(Effect.catch(() => Effect.void))
console.log(yield* TxRef.get(ref)) // => 0
})

Aborts the current transaction and suspends the fiber until one of the transactional values read in the body changes, then re-runs the body. It is the primitive behind every blocking Tx* operation. Its type is Effect<never, never, Effect.Transaction>, so it can only be used inside an Effect.tx boundary.

import { Effect, TxRef } from "effect"
// Wait until `gate` is opened (set to true) before proceeding.
const waitForOpen = (gate: TxRef.TxRef<boolean>) =>
Effect.tx(
Effect.gen(function*() {
const open = yield* TxRef.get(gate)
if (!open) return yield* Effect.txRetry // park until `gate` changes
})
)
// => the fiber suspends, then resumes once another transaction sets `gate` true

The Context.Service that holds the active transaction’s state — the journal of pending reads/writes and the retry flag. You rarely access it directly; it is the service requirement that Effect.tx provides and that Effect.txRetry depends on. Reading it confirms whether code is currently running inside a transaction.

import { Effect } from "effect"
const program = Effect.tx(
Effect.gen(function*() {
const state = yield* Effect.Transaction
console.log(state.retry) // => false
})
)