42 - Queue: Asynchronous Producer / Consumer Buffering
A Queue<A> is an asynchronous FIFO buffer. offer and take are Effects that suspend the fiber when the queue is full or empty, waking it up automatically when conditions change.
The capacity strategy decides what happens on overflow.
What you’ll work through
index.test.ts contains three beats: the four capacity strategies (bounded, unbounded, sliding, dropping) and how each handles overflow, basic offer / take semantics including back-pressure, and a producer-consumer pipeline assembled from two concurrent fibers.
By the end you should be able to choose between Queue (point-to-point buffer), PubSub (broadcast), and Semaphore (capacity gate) without hesitation.
Concept
Queue<A> asynchronous FIFO
Producer ──offer(item)──▶ [a, b, c] ──take──▶ Consumer
Capacity strategies: bounded ──▶ offer suspends when full (default for back-pressure) dropping ──▶ offer drops the new item (shed load) sliding ──▶ offer evicts the oldest (keep the newest) unbounded ──▶ no cap (memory risk; use sparingly)take always suspends when the queue is empty, there is no polling. takeAll returns whatever is available right now and never suspends.
Constructors
import { Queue, Effect } from "effect"
// ┌─── Effect<Queue.Queue<string>>// ▼const bounded = Queue.bounded<string>(100)const dropping = Queue.dropping<string>(100)const sliding = Queue.sliding<string>(100)const unbounded = Queue.unbounded<string>()When to Use
bounded, the safe default. Back-pressure protects the consumer.dropping, when missing a message is preferable to slowing the producer.sliding, telemetry, last-N readings, “latest wins” semantics.unbounded, only when both sides are clearly rate-matched.
Queue.offer and Queue.take
// ┌─── Effect<ReadonlyArray<string>>// ▼const program = Effect.gen(function* () { const queue = yield* Queue.bounded<string>(100) yield* Queue.offer(queue, "hello") yield* Queue.offer(queue, "world") const a = yield* Queue.take(queue) const b = yield* Queue.take(queue) return [a, b] as const})// ["hello", "world"]offer returns a boolean indicating whether the item was accepted (false for a dropping queue that discarded it). take returns A once one is available.
Queue.offerAll and Queue.takeAll
Bulk variants. takeAll returns a Chunk<A> of the items available right now, it does not wait.
yield* Queue.offerAll(queue, ["a", "b", "c"])const drained = yield* Queue.takeAll(queue) // Chunk<string>Queue.shutdown
Signals that no more items will be offered. Pending and future take calls fail with an interruption, which is how consumers learn the producer is done.
yield* Queue.shutdown(queue)Composite Example: Producer / Consumer
// ┌─── Effect<ReadonlyArray<number>>// ▼const program = Effect.gen(function* () { const queue = yield* Queue.bounded<number>(10) const processed: Array<number> = []
const producer = Effect.gen(function* () { for (let i = 1; i <= 5; i++) { yield* Queue.offer(queue, i) } })
const consumer = Effect.gen(function* () { for (let i = 0; i < 5; i++) { const item = yield* Queue.take(queue) processed.push(item * 2) } })
yield* Effect.all([producer, consumer], { concurrency: "unbounded" }) return processed})The producer and consumer run as separate fibers. Back-pressure handles the case where the producer is faster than the consumer.
Build a producer/consumer from scratch
The producer/consumer pattern is two fibers talking through a bounded Queue. The producer offers items; the consumer takes them.
The Queue takes care of back-pressure: when full it suspends the producer; when empty it suspends the consumer.
import { Effect, Queue } from "effect"
const program = Effect.gen(function* () { const queue = yield* Queue.bounded<number>(4)
const producer = Effect.gen(function* () { for (let i = 0; i < 10; i++) yield* queue.offer(i) })
const consumer = Effect.gen(function* () { const out: Array<number> = [] for (let i = 0; i < 10; i++) out.push(yield* queue.take) return out })
// Run both concurrently — Queue, Effect.all, and concurrency fit as one pattern. const [, result] = yield* Effect.all([producer, consumer], { concurrency: "unbounded", }) return result})Building this from scratch shows that there’s nothing magical about “producer/consumer” — it’s Queue + two concurrent Effects. Once the shape is in your head, every variation (multiple producers, multiple consumers, sliding overflow) is a one-line change.
Your turn wire a `producer` and a `consumer` against a single bounded `Queue`, run them concurrently with `Effect.all`, and assert the consumer collected every item the producer offered.
Common Pitfalls
1, Calling takeAll when you mean take. takeAll returns immediately with whatever is available, possibly nothing. take waits.
2, Reaching for Queue.unbounded to avoid back-pressure. Without a cap, a fast producer is a memory leak waiting to happen. Pick a finite bound and a strategy.
3, Treating Queue.shutdown as graceful flush. It is not. Shutdown interrupts pending takers.
To flush, drain explicitly with Queue.takeAll before shutting down.
4, Forgetting that Queue.offer on a dropping queue returns false on drop. Inspect the boolean if drop must be observed.
overflow. - [ ] Exercise `offer` / `take` and confirm back-pressure when the queue is full or empty. - [ ] Build a producer-consumer pipeline as two concurrent fibers connected by the queue.
See Also
- Previous,
47-cache: dedupe; the dual of distributing work. - Next,
36-fibers: the workers that drain a queue. - Related,
38-semaphore: limit how many workers run at once. - Related,
34-streams: when you want a higher-level pipeline, not raw queue ops.
Run the tests: pnpm test 42-queue
The full test suite
The index.test.ts for this lesson, verbatim from steps/42-queue/.
import { describe, it, expect } from "vite-plus/test"import { Effect, Fiber, Queue } from "effect"
// =============================================================================// 39 - Queue: Asynchronous Producer / Consumer Buffering// =============================================================================//// Queue.bounded / dropping / sliding / unbounded// Queue.offer / offerAll / take / takeAll / size / shutdown
describe("39 - Queue", () => { // @beat // id: offer-and-take // shape: one-todo // title: Offer and take preserve FIFO order // teaches: Queue.offer enqueues, Queue.take dequeues; items come back out in the order they went in. // hint: Queue.bounded<number>(10), then Queue.offer(queue, n) and yield* Queue.take(queue) describe("offer and take", () => { it("round-trips a single item", async () => { const program = /* @stub */ Effect.gen(function* () { const queue = yield* Queue.bounded<number>(10) yield* Queue.offer(queue, 42) return yield* Queue.take(queue) }) /* @end */
expect(await Effect.runPromise(program)).toBe(42) })
it("preserves FIFO order", async () => { const program = /* @stub */ Effect.gen(function* () { const queue = yield* Queue.bounded<number>(10) yield* Queue.offer(queue, 1) yield* Queue.offer(queue, 2) yield* Queue.offer(queue, 3) return [ yield* Queue.take(queue), yield* Queue.take(queue), yield* Queue.take(queue), ] }) /* @end */
expect(await Effect.runPromise(program)).toEqual([1, 2, 3]) }) }) // @end-beat
describe("offerAll and takeAll", () => { it("bulk-offers and drains available items", async () => { const program = Effect.gen(function* () { const queue = yield* Queue.unbounded<string>() yield* Queue.offerAll(queue, ["a", "b", "c"]) return yield* Queue.takeAll(queue) })
expect([...(await Effect.runPromise(program))]).toEqual(["a", "b", "c"]) }) })
// @beat // id: capacity-strategies // shape: multi-todo // title: Pick a capacity strategy // teaches: bounded back-pressures, dropping discards new items, sliding evicts the oldest — match the strategy to the overflow policy you want. // hint: Queue.bounded blocks the producer; Queue.dropping discards the new value; Queue.sliding evicts the head describe("Capacity strategies", () => { it("bounded queue applies back-pressure when full", async () => { const program = Effect.gen(function* () { const queue = yield* Queue.bounded<number>(2) yield* Queue.offer(queue, 1) yield* Queue.offer(queue, 2)
// Forked offer suspends until a take frees a slot. const producer = yield* Effect.forkChild(Queue.offer(queue, 3))
const taken = yield* Queue.take(queue) yield* Fiber.join(producer) const size = yield* Queue.size(queue) return { taken, size } })
const result = await Effect.runPromise(program) expect(result.taken).toBe(1) expect(result.size).toBe(2) // 2 and 3 remain })
it("dropping queue discards new items when full", async () => { const program = Effect.gen(function* () { const queue = yield* Queue.dropping<number>(2) yield* Queue.offer(queue, 1) yield* Queue.offer(queue, 2) yield* Queue.offer(queue, 3) // dropped return yield* Queue.takeAll(queue) })
expect([...(await Effect.runPromise(program))]).toEqual([1, 2]) })
it("sliding queue evicts the oldest item when full", async () => { const program = /* @stub */ Effect.gen(function* () { const queue = yield* Queue.sliding<number>(2) yield* Queue.offer(queue, 1) yield* Queue.offer(queue, 2) yield* Queue.offer(queue, 3) // 1 evicted return yield* Queue.takeAll(queue) }) /* @end */
expect([...(await Effect.runPromise(program))]).toEqual([2, 3]) }) }) // @end-beat
// @beat // id: producer-consumer // shape: one-todo // title: Run producer and consumer fibers concurrently // teaches: Queues suspend on offer when full and on take when empty — perfect for handing work between fibers. // hint: the producer offers 1..5 in order; the consumer doubles each — predict the final array describe("Producer / consumer", () => { it("runs producer and consumer fibers concurrently", async () => { const program = Effect.gen(function* () { const queue = yield* Queue.bounded<number>(10) const processed: Array<number> = []
const producer = Effect.gen(function* () { for (let i = 1; i <= 5; i++) { yield* Queue.offer(queue, i) } })
const consumer = Effect.gen(function* () { for (let i = 0; i < 5; i++) { processed.push((yield* Queue.take(queue)) * 2) } })
yield* Effect.all([producer, consumer], { concurrency: "unbounded" }) return processed })
// Predict the consumed-and-doubled array in FIFO order expect(await Effect.runPromise(program)).toEqual(/* @stub */ [2, 4, 6, 8, 10] /* @end */) }) }) // @end-beat
// @beat // id: producer-consumer-build // shape: from-scratch // title: Build the producer-consumer pipeline yourself // teaches: A Queue connects two concurrent fibers — the producer offers, the consumer takes. Build this from scratch to see how Queue, Effect.all, and concurrency fit together as one pattern. // hint: Queue.bounded<number>(10), then two generators — one offers 1..5, one takes 5 times; run both with Effect.all concurrency unbounded
describe("capstone: build the pipeline", () => { it("producer offers 1..5, consumer doubles each value — build both fibers", async () => { const processed: Array<number> = []
// ┌─── Effect<void, never, never> // ▼ const program = /* @stub */ Effect.gen(function* () { const queue = yield* Queue.bounded<number>(10)
const producer = Effect.gen(function* () { for (let i = 1; i <= 5; i++) { yield* Queue.offer(queue, i) } })
const consumer = Effect.gen(function* () { for (let i = 0; i < 5; i++) { const n = yield* Queue.take(queue) processed.push(n * 2) } })
yield* Effect.all([producer, consumer], { concurrency: "unbounded" }) }) /* @end */
await Effect.runPromise(program) expect(processed).toEqual([2, 4, 6, 8, 10]) }) }) // @end-beat})