Skip to content

Semaphore, Latch, and PartitionedSemaphore

Once you have many fibers, you need ways to coordinate them. Three small, composable primitives cover most needs:

  • A Semaphore limits how many fibers may hold a resource at once — perfect for capping concurrency against a connection pool, an API quota, or any shared resource that can’t take unlimited load.
  • A Latch is a gate: fibers wait on it until something opens it, then all waiters proceed. Use it to hold work back until initialization is done, or to pause and resume a pipeline.
  • A PartitionedSemaphore shares one bounded permit pool across keys (e.g. one tenant, customer, or host per key) so no single key can starve the others.

A semaphore holds a fixed number of permits. withPermits(n) acquires n permits before running an effect and releases them when the effect finishes — success, failure, or interruption. If not enough permits are free, the fiber suspends until they are. A semaphore with one permit is a mutex.

import { Effect, Semaphore } from "effect"
const program = Effect.gen(function*() {
// Create a semaphore that allows at most 2 holders at a time.
const semaphore = yield* Semaphore.make(2)
const task = (n: number) =>
// `withPermit` takes exactly one permit for the duration of the effect.
semaphore.withPermit(
Effect.gen(function*() {
yield* Effect.log(`task ${n} running`)
yield* Effect.sleep("500 millis")
yield* Effect.log(`task ${n} done`)
})
)
// Six tasks are forked, but the semaphore lets only 2 run concurrently.
yield* Effect.forEach([1, 2, 3, 4, 5, 6], task, {
concurrency: "unbounded"
})
})
Effect.runFork(program)

Here Effect.forEach with concurrency: "unbounded" wants to run all six tasks at once, but each task wraps itself in withPermit, so the semaphore caps the effective concurrency at two. The permit is released automatically when each task’s effect exits, so the next waiting fiber is admitted immediately.

The real power of a semaphore is sharing one instance everywhere. Hold it in a service so every consumer goes through the same gate:

import { Context, Effect, Layer, Semaphore } from "effect"
// A client that allows at most 5 concurrent outbound requests, process-wide.
export class RateLimitedApi extends Context.Service<RateLimitedApi, {
request<A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, E, R>
}>()("app/RateLimitedApi") {
static readonly layer = Layer.effect(
RateLimitedApi,
Effect.gen(function*() {
// One shared semaphore for the lifetime of the layer.
const semaphore = yield* Semaphore.make(5)
return RateLimitedApi.of({
// Every request, from anywhere, competes for the same 5 permits.
request: (effect) => Semaphore.withPermit(semaphore, effect)
})
})
)
}

Semaphore.make(n) and the standalone Semaphore.withPermit / withPermits functions are the data-first equivalents of the methods on the semaphore object — use whichever style reads better. You can also acquire several permits at once with withPermits(n), try without blocking via withPermitsIfAvailable, or adjust the limit at runtime with Semaphore.resize.

resize changes the future capacity while leaving currently-held permits untouched. Shrinking below the number of taken permits doesn’t revoke anything — new acquisitions simply wait until releases bring availability back up.

import { Effect, Semaphore } from "effect"
const program = Effect.gen(function*() {
const semaphore = yield* Semaphore.make(2)
// Bump the limit to 10 (e.g. after a config change).
yield* Semaphore.resize(semaphore, 10)
// Later, throttle back down to 1.
yield* Semaphore.resize(semaphore, 1)
})

A Latch is a one-bit gate that fibers can wait on. Its await effect suspends the calling fiber while the latch is closed, and resumes once it is opened. Opening is sticky: every fiber that waits after the latch opens proceeds immediately.

import { Effect, Latch } from "effect"
const program = Effect.gen(function*() {
// Start closed: waiters will suspend until we open it.
const latch = yield* Latch.make(false)
// Fork a worker that must wait for the green light before doing anything.
yield* Effect.forkChild(
Effect.gen(function*() {
yield* Effect.log("worker: waiting for the latch")
yield* latch.await
yield* Effect.log("worker: go!")
})
)
// Do some setup, then open the latch to release the worker.
yield* Effect.sleep("500 millis")
yield* Effect.log("main: setup done, opening latch")
yield* latch.open
})
Effect.runFork(program)
// worker: waiting for the latch
// main: setup done, opening latch
// worker: go!

A latch gives you three ways to control waiters, which matter when work arrives continuously:

  • open — release all current waiters and let future waiters pass immediately. The gate stays open.
  • release — wake the fibers currently waiting, but leave the latch closed so later waiters suspend again. A one-shot pulse.
  • close — re-close an open latch so future awaits suspend once more.
import { Effect, Latch } from "effect"
const program = Effect.gen(function*() {
const latch = yield* Latch.make(false)
// Pause a pipeline: close the latch and have stages await it.
yield* Latch.close(latch)
// ...later, resume just the in-flight waiters without re-opening...
yield* Latch.release(latch)
// ...or open it for good so everything flows.
yield* Latch.open(latch)
})

This open/release/close trio makes a latch a natural pause/resume switch for a streaming pipeline or a pool of workers: close to pause, open to resume. For a single one-time hand-off of a value between fibers — a producer computing a result that one consumer awaits — reach for a Deferred instead, which carries the value (or failure) along with the signal.

whenOpen is await followed by an effect, in one step: the effect runs only once the latch lets the fiber through.

import { Effect, Latch } from "effect"
const program = Effect.gen(function*() {
const latch = yield* Latch.make(false)
// This effect is held back until the latch opens, then runs.
yield* Effect.forkChild(
latch.whenOpen(Effect.log("ready — running gated work"))
)
yield* Effect.sleep("200 millis")
yield* latch.open
})

A plain Semaphore shares one global pool of permits. That’s a problem when a single noisy key — a tenant flooding requests, one slow host — grabs every released permit and starves everyone else. A PartitionedSemaphore<K> keeps the same fixed shared capacity, but tracks waiters per key and hands released permits back to waiting partitions in round-robin order, so each key keeps making progress.

import { Effect, PartitionedSemaphore } from "effect"
const program = Effect.gen(function*() {
// A shared pool of 4 permits, partitioned by tenant id (a string key).
const sem = yield* PartitionedSemaphore.make<string>({ permits: 4 })
const handle = (tenant: string, job: number) =>
// Take one permit *for this tenant*. If tenant "a" floods the pool,
// released permits still rotate to tenant "b" rather than being
// re-grabbed entirely by "a".
sem.withPermit(tenant)(
Effect.gen(function*() {
yield* Effect.log(`${tenant}: job ${job} start`)
yield* Effect.sleep("300 millis")
yield* Effect.log(`${tenant}: job ${job} done`)
})
)
yield* Effect.forEach(
[
handle("a", 1), handle("a", 2), handle("a", 3),
handle("b", 1), handle("b", 2)
],
(eff) => eff,
{ concurrency: "unbounded" }
)
})
Effect.runFork(program)

The key (tenant here) only affects how waiting permits are distributed — the total in-flight count never exceeds the shared permits capacity. Use withPermits(key, n) to weight a partition’s request by more than one permit.

Creates a Semaphore with permits total permits, inside Effect.

import { Effect, Semaphore } from "effect"
const program = Effect.gen(function*() {
const semaphore = yield* Semaphore.make(3)
// => Semaphore with 3 permits
})

Creates a Semaphore synchronously, for when you need the value outside an Effect workflow (e.g. a module-level shared limiter).

import { Semaphore } from "effect"
const semaphore = Semaphore.makeUnsafe(3)
// => Semaphore (constructed synchronously)

Acquires n permits around an effect, waiting until they’re available, and releases them when the effect exits (success, failure, or interruption).

import { Effect, Semaphore } from "effect"
const program = Effect.gen(function*() {
const sem = yield* Semaphore.make(5)
// Reserve 3 permits for this heavier piece of work.
yield* Semaphore.withPermits(sem, 3)(Effect.log("running with 3 permits"))
// => "running with 3 permits", then 3 permits released
})

The single-permit form of withPermits — a mutex when the semaphore has one permit.

import { Effect, Semaphore } from "effect"
const program = Effect.gen(function*() {
const sem = yield* Semaphore.make(1)
yield* Semaphore.withPermit(sem, Effect.log("exclusive section"))
// => "exclusive section"
})

Runs the effect only if n permits are free right now; never waits. Returns Option.some(result) if it ran, Option.none() if permits weren’t available.

import { Effect, Option, Semaphore } from "effect"
const program = Effect.gen(function*() {
const sem = yield* Semaphore.make(1)
// Manually take the only permit and never release it, draining the pool.
yield* Semaphore.take(sem, 1)
const result = yield* Semaphore.withPermitsIfAvailable(sem, 1)(
Effect.succeed("did the work")
)
// => Option.none() (no permit was available)
return Option.isNone(result)
})

Lower-level: acquires n permits, waiting if needed, and returns the number acquired. Pending takes are served first-in, first-out. Pair with release.

import { Effect, Semaphore } from "effect"
const program = Effect.gen(function*() {
const sem = yield* Semaphore.make(2)
const got = yield* Semaphore.take(sem, 2)
// => 2
})

Returns n permits to the pool (waking waiters as needed) and yields the resulting number of available permits. You must keep take/release balanced yourself — prefer the scoped withPermit(s) helpers when you can.

import { Effect, Semaphore } from "effect"
const program = Effect.gen(function*() {
const sem = yield* Semaphore.make(2)
yield* Semaphore.take(sem, 2)
const available = yield* Semaphore.release(sem, 1)
// => 1
})

Returns every currently-taken permit to the semaphore at once, yielding the resulting available count. Handy for cleanup of manual take/release protocols.

import { Effect, Semaphore } from "effect"
const program = Effect.gen(function*() {
const sem = yield* Semaphore.make(3)
yield* Semaphore.take(sem, 3)
const available = yield* Semaphore.releaseAll(sem)
// => 3
})

Sets the total permit count of an existing semaphore. Existing acquisitions stay taken; if the new total is below the taken count, new acquisitions wait.

import { Effect, Semaphore } from "effect"
const program = Effect.gen(function*() {
const sem = yield* Semaphore.make(2)
yield* Semaphore.resize(sem, 8)
// => future acquisitions can now take up to 8 permits
})

The semaphore value itself exposes the same operations as methods, so you can call semaphore.withPermit(eff) instead of Semaphore.withPermit(semaphore, eff): resize(permits), withPermits(permits)(effect), withPermit(effect), withPermitsIfAvailable(permits)(effect), take(permits), release(permits), and the releaseAll effect property.

import { Effect, Semaphore } from "effect"
const program = Effect.gen(function*() {
const sem = yield* Semaphore.make(2)
// Method form, equivalent to Semaphore.withPermit(sem, ...).
yield* sem.withPermit(Effect.log("via method"))
// => "via method"
})

Creates a Latch inside Effect. Starts closed by default; pass true to start open.

import { Effect, Latch } from "effect"
const program = Effect.gen(function*() {
const latch = yield* Latch.make(false)
// => closed Latch
})

Creates a Latch synchronously, outside Effect. Starts closed unless true is passed.

import { Latch } from "effect"
const latch = Latch.makeUnsafe(false)
// => closed Latch (constructed synchronously)

Opens the latch, releasing current and future waiters. Succeeds with true if this call changed the state (closed → open), false if it was already open.

import { Effect, Latch } from "effect"
const program = Effect.gen(function*() {
const latch = yield* Latch.make(false)
const changed = yield* Latch.open(latch)
// => true
})

Synchronous open: opens the latch immediately and returns whether the state changed.

import { Latch } from "effect"
const latch = Latch.makeUnsafe(false)
const changed = Latch.openUnsafe(latch)
// => true

Wakes the fibers currently waiting without opening the latch — future waiters still suspend. Succeeds with true if it ran while closed, false if already open.

import { Effect, Latch } from "effect"
const program = Effect.gen(function*() {
const latch = yield* Latch.make(false)
const released = yield* Latch.release(latch)
// => true (current waiters released; latch stays closed)
})

Re-closes an open latch so future await/whenOpen calls suspend again. Succeeds with true if it changed the state (open → closed), false otherwise.

import { Effect, Latch } from "effect"
const program = Effect.gen(function*() {
const latch = yield* Latch.make(true)
const changed = yield* Latch.close(latch)
// => true
})

Synchronous close: closes the latch immediately and returns whether the state changed.

import { Latch } from "effect"
const latch = Latch.makeUnsafe(true)
const changed = Latch.closeUnsafe(latch)
// => true

Waits on the latch, then runs the given effect, preserving its success, failure, and requirements. If the latch is open it runs immediately.

import { Effect, Latch } from "effect"
const program = Effect.gen(function*() {
const latch = yield* Latch.make(true)
const result = yield* Latch.whenOpen(latch, Effect.succeed(42))
// => 42
})

The latch value exposes its operations directly: the open, release, await, and close effect properties, the openUnsafe() / closeUnsafe() synchronous methods, and whenOpen(effect). Every operation also has a standalone function form (Latch.open, Latch.await, Latch.whenOpen, …) — use whichever style reads better.

import { Effect, Latch } from "effect"
const program = Effect.gen(function*() {
const latch = yield* Latch.make(true)
// Method form; `Latch.await(latch)` is the equivalent standalone function.
yield* latch.await
// => proceeds immediately (latch is open)
})

Creates a PartitionedSemaphore<K> inside Effect with a shared permits capacity, tracking waiters by key K.

import { Effect, PartitionedSemaphore } from "effect"
const program = Effect.gen(function*() {
const sem = yield* PartitionedSemaphore.make<string>({ permits: 4 })
// => PartitionedSemaphore keyed by string, capacity 4
})

Synchronous construction. Negative permits are clamped to 0; a non-finite value makes an unbounded (no-op) semaphore.

import { PartitionedSemaphore } from "effect"
const sem = PartitionedSemaphore.makeUnsafe<number>({ permits: 4 })
// => PartitionedSemaphore keyed by number, capacity 4

Reads a snapshot of how many permits are currently free (does not reserve any).

import { Effect, PartitionedSemaphore } from "effect"
const program = Effect.gen(function*() {
const sem = yield* PartitionedSemaphore.make<string>({ permits: 4 })
const free = yield* PartitionedSemaphore.available(sem)
// => 4
})

The fixed total capacity configured at construction. A plain number, not an effect — it never changes.

import { PartitionedSemaphore } from "effect"
const sem = PartitionedSemaphore.makeUnsafe<string>({ permits: 4 })
const cap = PartitionedSemaphore.capacity(sem)
// => 4

Manually acquires permits for key, waiting until released permits are assigned to this partition. The effect succeeds with void once the permits are held. Requests above capacity never complete; zero or negative requests complete without acquiring.

import { Effect, PartitionedSemaphore } from "effect"
const program = Effect.gen(function*() {
const sem = yield* PartitionedSemaphore.make<string>({ permits: 4 })
yield* PartitionedSemaphore.take(sem, "tenant-a", 2)
// => void (2 permits now held for "tenant-a")
})

Returns permits to the shared pool and yields the resulting available count. Released permits go first to waiting partitions (round-robin); leftovers raise availability, capped at capacity.

import { Effect, PartitionedSemaphore } from "effect"
const program = Effect.gen(function*() {
const sem = yield* PartitionedSemaphore.make<string>({ permits: 4 })
yield* PartitionedSemaphore.take(sem, "tenant-a", 2)
const available = yield* PartitionedSemaphore.release(sem, 2)
// => 4
})

Acquires permits for key around an effect, releasing them when it exits (success, failure, or interruption). The weighted, keyed equivalent of Semaphore.withPermits.

import { Effect, PartitionedSemaphore } from "effect"
const program = Effect.gen(function*() {
const sem = yield* PartitionedSemaphore.make<string>({ permits: 4 })
yield* PartitionedSemaphore.withPermits(sem, "tenant-a", 2)(
Effect.log("heavy work for tenant-a")
)
// => "heavy work for tenant-a", then 2 permits released
})

Single-permit form of withPermits, keyed by partition.

import { Effect, PartitionedSemaphore } from "effect"
const program = Effect.gen(function*() {
const sem = yield* PartitionedSemaphore.make<string>({ permits: 4 })
yield* PartitionedSemaphore.withPermit(sem, "tenant-b")(
Effect.log("one unit of work for tenant-b")
)
// => "one unit of work for tenant-b"
})

Runs the effect only if permits can be taken from the shared pool immediately — note this variant takes no key, since it never waits. Returns Option.some if it ran, Option.none() otherwise.

import { Effect, Option, PartitionedSemaphore } from "effect"
const program = Effect.gen(function*() {
const sem = yield* PartitionedSemaphore.make<string>({ permits: 1 })
yield* PartitionedSemaphore.take(sem, "tenant-a", 1) // drain the pool
const result = yield* PartitionedSemaphore.withPermitsIfAvailable(sem, 1)(
Effect.succeed("ran")
)
// => Option.none()
return Option.isNone(result)
})

interface PartitionedSemaphore and Partitioned

Section titled “interface PartitionedSemaphore and Partitioned”

PartitionedSemaphore<K> is the model; its members mirror the functions above (capacity, the available effect, take(key, permits), release(permits), withPermits(key, permits), withPermit(key), withPermitsIfAvailable(permits)), plus the PartitionedTypeId brand. Partitioned<K> is an alias interface with no extra members — an alternate exported name for the same shape.

import { Effect, PartitionedSemaphore } from "effect"
const program = Effect.gen(function*() {
const sem = yield* PartitionedSemaphore.make<string>({ permits: 4 })
// Method form, equivalent to PartitionedSemaphore.withPermit(sem, "k").
yield* sem.withPermit("tenant-a")(Effect.log("via method"))
// => "via method"
})

The runtime brand ("~effect/PartitionedSemaphore") stamped onto partitioned semaphore values; PartitionedTypeId is also exported as a type for fields that must hold that exact marker.

import { PartitionedSemaphore } from "effect"
const sem = PartitionedSemaphore.makeUnsafe<string>({ permits: 1 })
const brand = sem[PartitionedSemaphore.PartitionedTypeId]
// => "~effect/PartitionedSemaphore"