40 - SynchronizedRef: Effectful Atomic Updates
A SynchronizedRef<A> is a Ref<A> whose update function may itself be an Effect. Concurrent effectful updates are serialized: each fiber’s (A) => Effect<A> runs to completion before the next one starts.
This is the primitive for “read state, do async work, write state” with no race window.
What you’ll work through
index.test.ts covers the race-motivation beat, then updateEffect for serialized effectful updates, then modifyEffect for read-decide-write in one step, and finishes with a composite cache-fill example where two concurrent loads cannot collide.
By the end you should be able to identify the “read state, do async work, write state” pattern at sight and pick SynchronizedRef over Ref only when the update body contains a yield*.
Notice. Serialization is not free. Use
Refwhenever the update is pure —SynchronizedRefexists for the case where serialisation is the point.
Why SynchronizedRef exists
Ref.update works on a pure (A) => A transition — a compare-and-swap loop guarantees atomicity without locking. The moment your update body contains a yield* (a database lookup, an HTTP call, anything Effect- shaped), CAS no longer applies: between the read and the write another fiber could publish a new value, and your effectful update would clobber it.
SynchronizedRef is the locking variant — updateEffect accepts (A) => Effect<A> and serializes concurrent calls.
Ref update: (A) => A pure, lock-free atomic CASSynchronizedRef updateEffect: (A) => Effect<A> serialized, one update at a time
Two fibers calling SynchronizedRef.updateEffect:
fiber 1: read A0 ─▶ Effect<A1> ─▶ write A1 ─┐ fiber 2: wait ───────────────────▶ read A1 ─▶ Effect<A2> ─▶ write A2Rule of thumb. Reach for SynchronizedRef when the update body contains a yield* and you need the read-write pair to span that yield atomically. For pure transitions, stay with Ref — it’s lock-free and faster.
Your turn read the test that uses a plain `Ref` with an effectful update and observe the lost-update bug. Then read the partner test that uses `SynchronizedRef.updateEffect` and confirm both fibers' work is preserved.
SynchronizedRef.make and get
make mirrors Ref.make. get is identical, reads do not need serialization.
import { Effect, SynchronizedRef } from "effect"
// ┌─── Effect<SynchronizedRef<Map<string, number>>>// ▼const makeCache = SynchronizedRef.make<Map<string, number>>(new Map())SynchronizedRef.updateEffect
Applies an effectful update to the current value. While the update is running, every other updateEffect for the same ref is suspended.
// ┌─── Effect<void, E, R>// ▼const loadUser = ( cache: SynchronizedRef.SynchronizedRef<Map<string, number>>, userId: string,) => SynchronizedRef.updateEffect(cache, (map) => map.has(userId) ? Effect.succeed(map) : fetchScore(userId).pipe( Effect.map((score) => new Map(map).set(userId, score)), ), )Details
The signature is (a: A) => Effect<A, E, R>. Failures propagate; the ref is left unchanged if the effect fails.
Interruption is observed at the next yield point.
When to Use
Cache fills, lazy initialization, transactional state updates that read a database, batch flushes, any update where computing the new value is itself an Effect.
SynchronizedRef.modifyEffect
The synchronized counterpart to Ref.modify. The function takes the current state and returns Effect<[B, A]> — B is the value the caller receives, A is the new state to commit.
Read and write happen atomically across the yield, so even an effectful body cannot race itself against another modifyEffect on the same ref.
import { Effect, SynchronizedRef } from "effect"
// ┌─── Effect<string>// ▼const dequeue = (ref: SynchronizedRef.SynchronizedRef<ReadonlyArray<string>>) => SynchronizedRef.modifyEffect(ref, ([head, ...rest]) => Effect.succeed([head, rest] as [string, ReadonlyArray<string>]), )
// More realistically — the new state computation itself does IO:const dequeueWithAudit = ( ref: SynchronizedRef.SynchronizedRef<ReadonlyArray<string>>,) => SynchronizedRef.modifyEffect(ref, ([head, ...rest]) => Effect.gen(function* () { yield* auditService.recordDequeue(head) return [head, rest] as [string, ReadonlyArray<string>] }), )Rule of thumb. Reach for modifyEffect when the new state depends on the result of an Effect and the caller needs the read value as well. For “just update” use updateEffect; for “just read” use get.
Your turn complete a `modifyEffect` body that pops the head of a list and returns it alongside the new tail. Run two concurrent dequeues and confirm every queued item is observed exactly once.
Composite Example: Atomic Cache Fill
// ┌─── Effect<Map<string, number>>// ▼const program = Effect.gen(function* () { const cache = yield* SynchronizedRef.make<Map<string, number>>(new Map())
const fetchScore = (id: string) => Effect.succeed(id.length * 10)
const load = (id: string) => SynchronizedRef.updateEffect(cache, (map) => map.has(id) ? Effect.succeed(map) : fetchScore(id).pipe(Effect.map((score) => new Map(map).set(id, score))), )
yield* Effect.all([load("alice"), load("bob")], { concurrency: "unbounded" })
return yield* SynchronizedRef.get(cache)})Two concurrent loads, two distinct keys, no race: the second update sees the result of the first.
Common Pitfalls
1, Reaching for SynchronizedRef when Ref would do. Serialization is not free. If the update is pure, Ref.update is faster and simpler.
2, Reading then updating outside the lock.
// Wrong, race window between get and setconst score = yield* db.lookup(userId)yield* Ref.update(cache, (m) => new Map(m).set(userId, score))
// Right, fetch and update are one serialized stepyield* SynchronizedRef.updateEffect(cache, (m) => m.has(userId) ? Effect.succeed(m) : db.lookup(userId).pipe( Effect.map((score) => new Map(m).set(userId, score)), ),)3, Returning a mutated input. As with Ref, return a new value. The serializer protects against concurrent fibers, not against you mutating in place.
See Also
- Previous,
39-ref: the pure-update sibling. - Next,
46-scope: explicit resource lifecycles. - Related,
47-cache:Cachesolves the cache-fill case at a higher level. - Related,
36-fibers: concurrent fibers sharing state.
Run the tests: pnpm test 40-synchronized-ref
The full test suite
The index.test.ts for this lesson, verbatim from steps/40-synchronized-ref/.
import { describe, it, expect } from "vite-plus/test"import { Effect, Ref, SynchronizedRef } from "effect"
// =============================================================================// 36 - SynchronizedRef: Effectful Atomic Updates// =============================================================================//// SynchronizedRef.make Effect<SynchronizedRef<A>>// SynchronizedRef.get Effect<A>// SynchronizedRef.updateEffect (A) => Effect<A> serialized under concurrency// SynchronizedRef.modifyEffect (A) => Effect<[B, A]>
describe("36 - SynchronizedRef", () => { // @beat // id: race-motivation // shape: one-todo // title: Why plain Ref races on read-then-write // teaches: A plain Ref.get followed by Ref.set leaves a race window where concurrent fibers can lose writes. // hint: list every possible final value — either both updates land, or one is lost describe("Plain Ref vs SynchronizedRef", () => { it("plain Ref allows a read-then-write race", async () => { const racy = await Effect.runPromise( Effect.gen(function* () { const ref = yield* Ref.make(0) const update = Effect.gen(function* () { const current = yield* Ref.get(ref) yield* Ref.set(ref, current + 10) // race window }) yield* Effect.all([update, update], { concurrency: "unbounded" }) return yield* Ref.get(ref) }), ) // One of the two updates can be lost. // Predict the set of values racy can legally take (one update may be lost) expect(/* @stub */ [10, 20] /* @end */).toContain(racy) }) }) // @end-beat
// @beat // id: update-effect // shape: one-todo // title: Serialize effectful updates with updateEffect // teaches: SynchronizedRef.updateEffect runs (A) => Effect<A> as one indivisible step under concurrency. // hint: SynchronizedRef.updateEffect(ref, (n) => Effect.succeed(n + 10)) describe("SynchronizedRef.updateEffect", () => { it("runs an Effect to compute the next value", async () => { const program = /* @stub */ Effect.gen(function* () { const ref = yield* SynchronizedRef.make(0) yield* SynchronizedRef.updateEffect(ref, (n) => Effect.succeed(n + 10)) return yield* SynchronizedRef.get(ref) }) /* @end */
expect(await Effect.runPromise(program)).toBe(10) })
it("serializes concurrent updates — no lost writes", async () => { // ┌─── Effect<ReadonlyArray<number>> // ▼ const program = /* @stub */ Effect.gen(function* () { const ref = yield* SynchronizedRef.make<ReadonlyArray<number>>([]) const append = (n: number) => SynchronizedRef.updateEffect(ref, (arr) => Effect.succeed([...arr, n]))
yield* Effect.all([append(1), append(2), append(3)], { concurrency: "unbounded", })
return yield* SynchronizedRef.get(ref) }) /* @end */
const result = await Effect.runPromise(program) expect([...result].sort()).toEqual([1, 2, 3]) }) }) // @end-beat
// @beat // id: modify-effect // shape: worked-then-your-turn // title: Return a value with modifyEffect // teaches: SynchronizedRef.modifyEffect returns Effect<[B, A]> — B comes back to the caller, A is the new state. // hint: SynchronizedRef.modifyEffect(ref, (n) => Effect.succeed([n, n * 2])) describe("SynchronizedRef.make and get", () => { it("reads back the initial value", async () => { const program = Effect.gen(function* () { const ref = yield* SynchronizedRef.make(0) return yield* SynchronizedRef.get(ref) })
expect(await Effect.runPromise(program)).toBe(0) }) })
describe("SynchronizedRef.modifyEffect", () => { it("returns a value alongside the new state — atomically", async () => { const program = /* @stub */ Effect.gen(function* () { const ref = yield* SynchronizedRef.make(100) const old = yield* SynchronizedRef.modifyEffect( ref, (n) => Effect.succeed([n, n * 2]) as Effect.Effect<[number, number]>, ) const current = yield* SynchronizedRef.get(ref) return { old, current } }) /* @end */
const result = await Effect.runPromise(program) expect(result.old).toBe(100) expect(result.current).toBe(200) }) }) // @end-beat
// @beat // id: cache-fill // shape: one-todo // title: Atomic cache-fill pattern // teaches: Using updateEffect for "check then fetch then store" eliminates the race window plain Ref leaves open. // hint: wrap the whole read-fetch-write inside one SynchronizedRef.updateEffect describe("Effectful cache fill", () => { it("SynchronizedRef closes the race window plain Ref leaves open", async () => { // SynchronizedRef: read and write are one serialized step. const safe = await Effect.runPromise( /* @stub */ Effect.gen(function* () { const ref = yield* SynchronizedRef.make(0) const update = SynchronizedRef.updateEffect(ref, (n) => Effect.succeed(n + 10)) yield* Effect.all([update, update], { concurrency: "unbounded" }) return yield* SynchronizedRef.get(ref) }) /* @end */, ) expect(safe).toBe(20) }) }) // @end-beat})