45 - PubSub: Fan-Out to Multiple Consumers
A Queue is point-to-point: one producer, one consumer per message. A PubSub is broadcast: every active subscriber receives a copy of every published message.
Each subscriber gets its own independent buffer — no subscriber starves another, and a slow subscriber does not block the publisher (depending on the overflow strategy).
What you’ll work through
index.test.ts contains four beats: basic publish / subscribe, batching with publishAll, the Queue-vs-PubSub comparison that makes the choice explicit, and the subscriber lifecycle tied to a Scope so subscriptions clean up automatically.
By the end you should know when fan-out is needed (PubSub) vs when one producer feeds one consumer (Queue).
Mental Model
Queue (point-to-point) PubSub (broadcast)────────────────────── ──────────────────publisher ──► [A, B, C] publisher ──► hub │ │ consumer-1 takes A subscriber-1 receives [A, B, C] consumer-2 takes B subscriber-2 receives [A, B, C] consumer-1 takes C subscriber-3 receives [A, B, C]
Each message goes to ONE consumer. Each message goes to ALL subscribers.Use Queue when work should be distributed. Use PubSub when every consumer needs every event — cache invalidation, event sourcing, fan-out notifications.
Subscribe and publish
PubSub.subscribe(hub) returns a scoped Queue. The scope controls the subscription lifetime — when the enclosing Effect.scoped block exits, the subscriber is automatically unregistered.
import { Effect, PubSub, Queue } from "effect"
const program = Effect.gen(function* () { const hub = yield* PubSub.bounded<string>(10)
yield* Effect.scoped( Effect.gen(function* () { const sub1 = yield* PubSub.subscribe(hub) const sub2 = yield* PubSub.subscribe(hub)
yield* PubSub.publish(hub, "hello")
const a = yield* Queue.take(sub1) // "hello" const b = yield* Queue.take(sub2) // "hello" return [a, b] }) )})When to Use
Any time multiple consumers need to react to the same events in parallel — metrics collectors, audit loggers, cache invalidators, UI layers.
Your turn subscribe two consumers, publish two messages, and confirm both receive both.
Batch publish with publishAll
PubSub.publishAll(hub, messages) enqueues a collection atomically. All subscribers see all messages in order, with no interleaving from other publishers.
This is the right primitive when a producer has a logical “frame” of messages that must arrive together — a transaction’s events, a tick of state changes, a batch fetched from a remote source — because no other publisher can wedge a message into the middle.
// ┌─── Effect<boolean, never, never>// ▼yield* PubSub.publishAll(hub, [1, 2, 3])
// Each subscriber sees the messages in their original order:// sub-A drains: 1, 2, 3// sub-B drains: 1, 2, 3 (independent copy)Rule of thumb. Reach for
publishAllwhenever the loop body isfor (m of msgs) yield* PubSub.publish(hub, m). The batch form is cheaper and atomic — strict win.
Your turn replace the stub with `yield* PubSub.publishAll(hub, [1, 2, 3])`, then drain the subscriber three times with `Queue.take(sub)`. The test asserts the results array equals `[1, 2, 3]` in order.
Queue vs PubSub
Queue |
PubSub |
|
|---|---|---|
| Routing | Point-to-point | Broadcast |
| Consumer sees | One message per take | All messages |
| Use case | Task distribution, work queues | Event fan-out, notifications |
// Queue — each take removes the item.yield* Queue.offer(queue, "msg")const a = yield* Queue.take(queue) // "msg" — goneconst b = yield* Queue.take(queue) // blocks — queue is empty
// PubSub — each subscriber has its own copy.yield* PubSub.publish(hub, "msg")const a = yield* Queue.take(sub1) // "msg"const b = yield* Queue.take(sub2) // "msg"Your turn read both tests — the contrast shows exactly where each primitive applies.
Subscription lifetime
Because PubSub.subscribe is scoped, the subscription ends when the Effect.scoped block exits. Messages published after the scope closes are not delivered to that subscriber.
yield* Effect.scoped( Effect.gen(function* () { const sub = yield* PubSub.subscribe(hub) yield* PubSub.publish(hub, "inside") yield* Queue.take(sub) // "inside" }))// sub is now unregisteredyield* PubSub.publish(hub, "outside") // not delivered to old subThis prevents resource leaks: a subscriber that exits its scope is removed from the hub automatically, with no manual cleanup needed.
Your turn read the test — publish after the scope closes and confirm the subscriber got nothing.
Overflow Strategies
PubSub offers the same capacity strategies as Queue:
| Constructor | On overflow |
|---|---|
PubSub.bounded(n) |
Back-pressures the publisher |
PubSub.dropping(n) |
Discards new messages |
PubSub.sliding(n) |
Evicts oldest messages |
PubSub.unbounded() |
No limit (careful in production) |
Common Pitfalls
1, Subscribing after publishing. A subscriber only receives messages published after it subscribed. Subscribe first, then publish.
2, Forgetting Effect.scoped. PubSub.subscribe is a scoped resource. Without Effect.scoped, the subscription is never closed and the subscriber leaks.
3, Using PubSub where Queue fits. If you have one producer and one consumer and want load distribution, use Queue. PubSub clones every message for every subscriber, which has overhead.
copy. - [ ] Batch publishes with `publishAll`. - [ ] Compare against a Queue for the same scenario — the broadcast vs point-to-point difference is the point. - [ ] Tie a subscriber to a `Scope` so it cleans up automatically.
See Also
- Previous,
38-semaphore: rate-limiting concurrent access to a shared resource. - Previous,
42-queue: point-to-point producer/consumer buffering. - Related,
36-fibers: fork and join for manual concurrency control.
Run the tests: pnpm test 45-pubsub
The full test suite
The index.test.ts for this lesson, verbatim from steps/45-pubsub/.
import { describe, expect, it } from "vite-plus/test"import { Effect, PubSub } from "effect"
describe("PubSub", () => { it("broadcasts each message to every scoped subscriber", async () => { const program = Effect.scoped(Effect.gen(function*() { const bus = yield* PubSub.bounded<string>(4) const left = yield* PubSub.subscribe(bus) const right = yield* PubSub.subscribe(bus) yield* PubSub.publish(bus, "ready") return [yield* PubSub.take(left), yield* PubSub.take(right)] })) expect(await Effect.runPromise(program)).toEqual(["ready", "ready"]) })})