07·04PubSub

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.

45 - PubSub: Fan-Out to Multiple Consumers

Previous · Next

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)
> back-pressure policy you chose at construction — `bounded` suspends > when the slowest subscriber is full, `dropping` discards latest, > `sliding` evicts oldest. The batch is atomic at the *publish*; it's > not a transactional guarantee against slow subscribers.

Rule of thumb. Reach for publishAll whenever the loop body is for (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" — gone
const 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 unregistered
yield* PubSub.publish(hub, "outside") // not delivered to old sub

This 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/.

index.test.ts
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"])
})
})
← PreviousLatchNext →Scope