44 - Latch: Barrier Synchronization
A Latch is a reusable gate. Close it to hold fibers at a barrier; open it to release all waiters simultaneously.
Unlike Deferred, a Latch carries no value — it is purely a signal. Unlike Deferred, it can be closed again after opening for a second wave.
What you’ll work through
index.test.ts contains four beats: opening a Latch and releasing waiters with await, holding multiple waiters at the same barrier and releasing them in one shot, closing the Latch again for a second wave, and the whenOpen shortcut that gates an Effect on the Latch’s state.
By the end you should pick Latch for “release N fibers at once” and Deferred for “deliver one value to N waiters”.
Mental Model
Latch (open = false) ┌─────────────────┐ fiber-A ──► await │ CLOSED │ suspends fiber-B ──► await │ CLOSED │ suspends fiber-C ──► await │ CLOSED │ suspends └─────────────────┘ │ latch.open() │ ┌─────────────────┐ │ OPEN │ all waiters released at once └─────────────────┘ │ │ │ fiber-A fiber-B fiber-C resumeLatch.make(false) creates a closed latch. latch.open releases all suspended fibers.
latch.close resets the gate for the next wave.
Create and open
Latch.make(open?) returns a Latch. Passing false (or omitting the argument) starts it closed.
latch.open releases every fiber blocked on latch.await.
import { Effect, Fiber } from "effect"
const program = Effect.gen(function* () { const latch = yield* Latch.make(false)
const fiber = yield* Effect.forkChild( latch.await.pipe(Effect.andThen(Effect.log("released"))) )
yield* Effect.sleep("100 millis") yield* latch.open // fiber resumes here yield* Fiber.join(fiber)})When to Use
Use a Latch when multiple fibers need to wait for a common start signal — a readiness check, a config load, a test setup step. For a one-time value hand-off use Deferred (step 43).
Your turn create a closed latch, fork a waiter, open the latch, and join the fiber.
Release N fibers simultaneously
latch.open is not a message — it is a state change. Every fiber currently suspended on latch.await resumes the moment the latch opens.
No queue, no per-fiber call needed.
// Three fibers, one open signal.const fibers = yield* Effect.all( [1, 2, 3].map((n) => Effect.forkChild(latch.await.pipe(Effect.as(n)))))yield* latch.openconst results = yield* Effect.all(fibers.map(Fiber.join))// results === [1, 2, 3]The order of results matches the order the fibers were forked, not the order they happened to resume — Fiber.join produces each fiber’s exit value, and the Effect.all over the array stitches them back into the original positions. This is the standard “start everyone, then release” shape for benchmarks, load tests, and concurrent setup.
Your turn read the test — three fibers fork and suspend on `latch.await`, then a single `yield* latch.open` releases them in one shot. Confirm the joined results are `[1, 2, 3]` regardless of resume order.
Close and reuse
latch.close returns the latch to the closed state. Fibers that arrive after close block until the next open.
This makes a Latch suitable for repeating phase barriers — unlike Deferred, which completes exactly once.
Wave 1 Wave 2────── ──────latch.open → releases latch.close → closed again latch.open → releases wave-2 fibersconst latch = yield* Latch.make(false)yield* latch.open // wave 1 releasedyield* latch.close // gate shut againconst wave2 = yield* Effect.forkChild(latch.await)yield* latch.open // wave 2 releasedRule of thumb. Reusability is the headline feature that distinguishes
LatchfromDeferred. If your code has a “phase” shape — initialise, run, reset, run again —Latchis exactly the shape you want. ADeferredthat completes once is the wrong tool.
Your turn open the latch (wave 1 releases), `latch.close` it (gate shut), fork a new waiter for wave 2, then open it again. The test asserts both waves released cleanly and the fiber order tracked each open call.
whenOpen — run an effect only when open
latch.whenOpen(effect) is equivalent to latch.await.pipe(Effect.andThen(effect)) but reads as a single combinator. It captures the most common shape — “do this after the gate is up” — in one call, and keeps the latch-vs-effect relationship visible at the call site.
const fiber = yield* Effect.forkChild( latch.whenOpen(Effect.log("gate is open")))yield* latch.open // "gate is open" logs hereyield* Fiber.join(fiber)Rule of thumb. Reach for
whenOpenwhen you have one effect to gate. For a longer sequence, the explicitlatch.await+Effect.genform reads better and lets you put the gate at exactly the right point rather than wrapping the whole block.
Your turn fork `latch.whenOpen(...)` with an effect that pushes to an audit array, confirm the audit is still empty *before* `latch.open`, then open the latch and assert the audit contains the entry — proof the effect was deferred until the latch flipped.
Common Pitfalls
1, Using Latch when you need a value. A Latch is a signal. If the producer needs to hand a value to the consumers, use Deferred<A> instead.
2, Forgetting to close between waves. If you open the latch for wave 1 and then fork wave-2 fibers without calling close, they will not block — the latch is still open.
3, Awaiting a latch that is never opened. Like Deferred.await, this suspends forever. Compose with Effect.timeout if the open may not happen.
all waiters release in one shot. - [ ] Close the Latch again and run a second wave. - [ ] Use `whenOpen` to gate an Effect on the Latch's state.
See Also
- Previous,
43-deferred: one-shot value hand-off between fibers. - Next,
36-fibers:Effect.forkChild,forkDaemon, structured concurrency. - Related,
45-pubsub: broadcast values (not signals) to multiple subscribers.
Run the tests: pnpm test 44-latch
The full test suite
The index.test.ts for this lesson, verbatim from steps/44-latch/.
import { describe, it, expect } from "vite-plus/test"import { Effect, Fiber, Latch } from "effect"
// =============================================================================// 39c - Latch: Barrier Synchronization// =============================================================================//// Latch.make(open?) create a gate (default closed) — Effect<Latch>// latch.await block until the latch is open — Effect<void>// latch.open open the gate, releasing waiters — Effect<void>// latch.close close the gate for the next wave — Effect<void>// latch.whenOpen(effect) run effect only when open — Effect<A>//// A Latch is a reusable gate: close it to hold fibers, open it to release them.// Unlike Deferred, a Latch carries no value — it is purely a signal.// Unlike Deferred, a Latch can be closed again after opening for a second wave.
describe("39c - Latch", () => { // @beat // id: open-and-await // shape: one-todo // title: Create a closed latch and open it // teaches: Latch.make(false) starts closed; latch.await suspends; latch.open unblocks all waiters. // hint: yield* Latch.make(false), fork a fiber that does latch.await, then yield* latch.open describe("Latch.make and latch.open", () => { it("open releases a fiber blocked on await", async () => { // ┌─── Effect<void> // ▼ const program = /* @stub */ Effect.gen(function* () { const latch = yield* Latch.make(false)
const waiter = yield* Effect.forkChild(latch.await)
yield* latch.open
yield* Fiber.join(waiter) }) /* @end */
await expect(Effect.runPromise(program)).resolves.toBeUndefined() })
it("already-open latch does not block", async () => { const program = Effect.gen(function* () { const latch = yield* Latch.make(true) // starts open yield* latch.await // returns immediately return "passed" })
expect(await Effect.runPromise(program)).toBe("passed") }) }) // @end-beat
// @beat // id: multiple-waiters // shape: one-todo // title: Release N fibers simultaneously // teaches: All fibers suspended on latch.await are released at once when latch.open is called — a single signal fans out to every waiter. // hint: fork N waiters before opening; join all after — they all resume from the same open signal describe("multiple waiters", () => { it("releases all suspended fibers when opened", async () => { const program = Effect.gen(function* () { const latch = yield* Latch.make(false) const log: number[] = []
// Fork three fibers — all block on latch.await. const fibers = yield* Effect.all( [1, 2, 3].map((n) => Effect.forkChild( latch.await.pipe(Effect.andThen(Effect.sync(() => log.push(n)))), ), ), )
// Open the gate — all three are released simultaneously. yield* latch.open yield* Effect.all(fibers.map(Fiber.join))
return log.sort() })
// Predict the sorted log after all three waiters resume. expect(await Effect.runPromise(program)).toEqual(/* @stub */ [1, 2, 3] /* @end */) }) }) // @end-beat
// @beat // id: close-and-reuse // shape: one-todo // title: Close and reuse the latch for a second wave // teaches: latch.close resets the gate so later fibers block again — a Latch is reusable across multiple open/close cycles, unlike Deferred which completes exactly once. // hint: yield* latch.close after the first wave completes, then open again for the second wave describe("latch.close — reuse for a second wave", () => { it("blocks a second wave after close, releases on second open", async () => { const program = /* @stub */ Effect.gen(function* () { const latch = yield* Latch.make(false) const log: string[] = []
// Wave 1 const w1 = yield* Effect.forkChild( latch.await.pipe(Effect.andThen(Effect.sync(() => log.push("wave-1")))), ) yield* latch.open yield* Fiber.join(w1)
// Close for wave 2 yield* latch.close
// Wave 2 const w2 = yield* Effect.forkChild( latch.await.pipe(Effect.andThen(Effect.sync(() => log.push("wave-2")))), ) yield* latch.open yield* Fiber.join(w2)
return log }) /* @end */
expect(await Effect.runPromise(program)).toEqual(["wave-1", "wave-2"]) }) }) // @end-beat
// @beat // id: when-open // shape: one-todo // title: Run an effect only when the latch is open // teaches: latch.whenOpen(effect) suspends until open, then immediately runs effect — shorter than await then flatMap when the pattern is repeated. // hint: predict the one entry the gated effect pushes into log after latch.open describe("latch.whenOpen", () => { it("runs the effect only after the latch opens", async () => { const program = Effect.gen(function* () { const latch = yield* Latch.make(false) const log: string[] = []
const fiber = yield* Effect.forkChild( latch.whenOpen(Effect.sync(() => log.push("ran"))), )
// The fiber is suspended — nothing has run yet. expect(log).toEqual([])
yield* latch.open yield* Fiber.join(fiber)
return log })
// Predict the log after latch.open releases the whenOpen fiber expect(await Effect.runPromise(program)).toEqual(/* @stub */ ["ran"] /* @end */) }) }) // @end-beat})