07·03Latch

A `Latch` is a reusable gate. Close it to hold fibers at a barrier; open it to release all waiters simultaneously.

44 - Latch: Barrier Synchronization

Previous · Next

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 resume

Latch.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.open
const 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.

> already-open latch is a no-op. To run a *second* wave with the same > latch, call `latch.close` first (next beat). Forgetting the close is > the most common reason "the second batch didn't wait".

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 fibers
const latch = yield* Latch.make(false)
yield* latch.open // wave 1 released
yield* latch.close // gate shut again
const wave2 = yield* Effect.forkChild(latch.await)
yield* latch.open // wave 2 released

Rule of thumb. Reusability is the headline feature that distinguishes Latch from Deferred. If your code has a “phase” shape — initialise, run, reset, run again — Latch is exactly the shape you want. A Deferred that 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 here
yield* Fiber.join(fiber)

Rule of thumb. Reach for whenOpen when you have one effect to gate. For a longer sequence, the explicit latch.await + Effect.gen form 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/.

index.test.ts
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
})
← PreviousDeferredNext →PubSub