Skip to content

SynchronizedRef

A SynchronizedRef<A> is a Ref whose updates can run effects while computing the next value — and whose effectful updates are serialized so they never overlap. Internally it guards updates with a semaphore: while one effectful update runs, any other update to the same reference waits its turn, then re-reads the now-current value.

Use it when the next value depends on an effect (a network call, a database lookup, a Clock read) and those updates must not race with one another.

With a plain Ref you could read the value, run an effect, then write the result back — but that is three separate operations. Between the read and the write, another fiber can change the value, and your write silently overwrites theirs. SynchronizedRef.updateEffect closes that gap: it holds the lock for the whole read → effect → write cycle.

import { Effect, SynchronizedRef } from "effect"
// Pretend this fetches the latest score for a user from a remote service.
const fetchDelta = (current: number): Effect.Effect<number> =>
Effect.succeed(current + 10)
const program = Effect.gen(function*() {
const score = yield* SynchronizedRef.make(0)
// The update function returns an Effect. The whole read-run-write cycle is
// serialized, so concurrent updates apply one after another instead of
// racing on a stale value.
const bump = SynchronizedRef.updateEffect(score, (n) => fetchDelta(n))
// Run several effectful updates concurrently — they still compose correctly.
yield* Effect.all([bump, bump, bump], { concurrency: "unbounded" })
return yield* SynchronizedRef.get(score) // 30
})

If score were a plain Ref and we tried to express this as a get, then a fetch, then a set, the three concurrent updates could each read 0 and write 10, losing two of the three increments. With SynchronizedRef the result is always 30.

SynchronizedRef supports every operation a Ref does — get, set, update, modify, and their andGet / getAnd / Some variants — so you can use it as a drop-in replacement. The addition is the Effect suffix family, where the update function returns an effect:

  • updateEffect — replace the value with the result of an effectful function.
  • updateAndGetEffect / getAndUpdateEffect — the same, returning the new or previous value.
  • modifyEffect — effectfully compute both a return value and the next stored value.
  • updateSomeEffect, modifySomeEffect, … — conditional effectful updates driven by an Option.
import { Effect, SynchronizedRef } from "effect"
const program = Effect.gen(function*() {
const ref = yield* SynchronizedRef.make(100)
// modifyEffect returns a tuple [result, newValue]. The result is handed back
// to the caller; the newValue is stored. Both are computed atomically under
// the lock, even though the computation is effectful.
const withdrawn = yield* SynchronizedRef.modifyEffect(ref, (balance) =>
Effect.gen(function*() {
const amount = balance >= 30 ? 30 : 0
yield* Effect.log(`Withdrawing ${amount} from ${balance}`)
return [amount, balance - amount] as const
}))
const remaining = yield* SynchronizedRef.get(ref)
return { withdrawn, remaining } // { withdrawn: 30, remaining: 70 }
})

A common use is coordinating access to a shared, effectfully-managed resource. Here a service hands out connections from a pool, replenishing it with an effect when it runs dry — and SynchronizedRef guarantees two fibers never grab the same slot.

import { Context, Effect, Layer, SynchronizedRef } from "effect"
class ConnectionPool extends Context.Service<ConnectionPool, {
// Borrow the next available connection id, opening more if needed.
readonly acquire: Effect.Effect<number>
}>()("app/ConnectionPool") {
static layer = Layer.effect(
ConnectionPool,
Effect.gen(function*() {
// State: the ids currently free to hand out.
const free = yield* SynchronizedRef.make<Array<number>>([])
let nextId = 0
const openConnection = Effect.sync(() => nextId++)
const acquire = SynchronizedRef.modifyEffect(free, (available) =>
available.length > 0
// Fast path: reuse a free connection. No effect needed, but the whole
// decision is still serialized so no two fibers take the same id.
? Effect.succeed([available[0], available.slice(1)] as const)
// Slow path: open a new connection effectfully while holding the lock.
: Effect.map(openConnection, (id) => [id, [] as Array<number>] as const))
return ConnectionPool.of({ acquire })
})
)
}
const program = Effect.gen(function*() {
const pool = yield* ConnectionPool
// Even acquiring concurrently, each fiber receives a distinct id.
const ids = yield* Effect.all(
[pool.acquire, pool.acquire, pool.acquire],
{ concurrency: "unbounded" }
)
return ids // three distinct connection ids
}).pipe(Effect.provide(ConnectionPool.layer))

If your update is a pure function of the current value, a plain Ref is already atomic and has no locking overhead — use it. Reach for SynchronizedRef only when computing the next value requires an effect that must stay serialized. And if you need the whole group of references to update transactionally together, use transactions (STM).