Code an in-process publish-subscribe system
Open in the interactive app: practice, quiz, mock interview →In one minute
- An in-process pub/sub system is a router plus a set of queues. Publishers hand a message to a topic; the broker copies a reference to every matching subscription; each subscription drains its own queue at its own pace. Everything interesting happens in the gap between "publish returned" and "subscriber handled it".
- The decision that makes or breaks the design is what happens when one subscriber is slow. Each subscription needs its own bounded buffer and an explicit overflow policy (drop oldest, drop newest, or push back on the publisher). An unbounded queue just moves the failure to an out-of-memory crash.
- Keep the publisher ignorant of subscribers. Publish never calls a handler directly and never waits on one, or a single slow or throwing handler stalls every publisher in the process.
- Messages are immutable values with a per-topic sequence number. The sequence lets a subscriber detect gaps after a drop, and immutability lets the broker share one copy across a thousand subscriptions safely.
- The shape is fan-out with overload at the edges. The same object model shows up as a UI event bus, a metrics pipeline that feeds several exporters, a game engine's event system and the core of a broker such as the one in Design Messaging Queue.
Requirements
Functional:
- Publishers publish a message (payload plus string attributes) to a named topic. Topics are created on first use.
- Subscribers subscribe to one topic, optionally with an attribute filter such as
region = eu, and receive every matching message published after they subscribed, in publish order. - Each subscription has a capacity and an overflow policy: drop the oldest queued message, drop the new one, or reject the publish.
- Subscribers can unsubscribe at any time; pending messages for that subscription are discarded.
- The broker reports per-subscription counters: queued, delivered and dropped.
Non-functional:
- A slow or failing subscriber must not delay other subscribers or the publisher (except under the explicit "reject" policy).
- Per-topic ordering is preserved for each subscriber. There is no ordering promise across topics.
- Bounded memory: the total buffered data is at most the sum of subscription capacities.
- Publish cost should be close to O(matching subscriptions), with no I/O on the publish path.
Out of scope: durability across process restarts, cross-process delivery, wildcard topic hierarchies, consumer groups that share a subscription, and exactly-once processing. Say that durability turns this into a log-based broker and point to the system design lessons; then design the in-memory core well. The Pub/Sub lesson covers durable hubs and topic wildcards in depth.
Entities and relationships
- Broker is the façade. It owns the topic registry and the subscription index, assigns sequence numbers and runs the routing step.
- Topic is a name plus its next sequence number and an ordered list of subscriptions. It exists to make "who gets this message" a single lookup.
- Message is an immutable value: topic, sequence, payload, attributes, publish time. One instance is shared by every subscription that receives it.
- Subscription is the unit of isolation. It holds the filter, a bounded FIFO buffer, the overflow policy, the counters and a reference to the subscriber. It is where back pressure is decided.
- Subscriber is the consumer interface:
on_message(message). In push mode a dispatcher calls it; in pull mode the subscriber callspollitself. - MessageFilter decides whether a message matches a subscription. OverflowPolicy decides what to do when a buffer is full. Clock stamps publish time.
Relationships: a broker is composed of topics; a topic aggregates subscriptions (a subscription references the topic by name and can be removed independently). A subscription owns its buffer and points at, but does not own, its subscriber. Messages are referenced from many buffers and owned by none, which is why they must be immutable.
A common first draft has Topic loop over subscriber callbacks inside publish. That is Observer, not pub/sub: the publisher's thread runs every handler. The Subscription with its own buffer is what turns one into the other.
Class diagram
classDiagram
class Broker {
-topics: Map~string, Topic~
-subs: Map~SubId, Subscription~
+subscribe(topic, id, capacity, policy, filter) Subscription
+unsubscribe(id) bool
+publish(topic, payload, attrs) int
+poll(id, max) Message[]
}
class Topic {
+name: string
-nextSeq: int
-subscriptions: Subscription[]
}
class Message {
+topic: string
+seq: int
+payload: bytes
+attrs: Map~string, string~
+publishedAt: int
}
class Subscription {
+id: SubId
-buffer: RingBuffer~Message~
-dropped: int
+offer(msg) OfferResult
+take(max) Message[]
}
class MessageFilter {
<<interface>>
+matches(msg) bool
}
class OverflowPolicy {
<<interface>>
+onFull(buffer, msg) OfferResult
}
class Subscriber {
<<interface>>
+onMessage(msg)
}
class Dispatcher {
+run(subscription)
}
Broker "1" *-- "many" Topic
Topic "1" o-- "many" Subscription
Subscription --> MessageFilter
Subscription --> OverflowPolicy
Subscription --> Subscriber
Subscription ..> Message
Dispatcher --> Subscription
DropOldest ..|> OverflowPolicy
DropNewest ..|> OverflowPolicy
RejectPublish ..|> OverflowPolicy
AttributeEquals ..|> MessageFilterKey flows
Publish is a two-phase step under the topic's lock: first check whether any matching subscription would reject, then enqueue into every matching buffer. Delivery happens later, on each subscription's own dispatcher, so the publisher never runs subscriber code.
sequenceDiagram
participant P as Publisher
participant B as Broker
participant T as Topic
participant S1 as Subscription A
participant S2 as Subscription B
participant D as Dispatcher B
participant H as Handler B
P->>B: publish("orders", payload, attrs)
B->>T: lock, match filters
T->>S1: would reject?
T->>S2: would reject?
T->>T: assign seq 41
T->>S1: offer(msg 41)
T->>S2: offer(msg 41)
T-->>B: unlock
B-->>P: seq 41
D->>S2: take(batch of 32)
S2-->>D: msgs 39..41
D->>H: onMessage(39), (40), (41)Design patterns used and why
- Façade:
Brokeris the only type publishers and subscribers see. It hides topics, buffers and dispatchers, so you can swap the ring buffer for a lock-free one, or the dispatcher for a shared thread pool, without touching callers. - Strategy for overflow. Different consumers want different behaviour when full: a live price feed wants the newest value (drop oldest), an audit consumer wants none silently lost (reject, so the publisher feels it), a debug tap wants to cost nothing (drop newest). One interface, three small classes, chosen per subscription.
- Specification for filters.
AttributeEquals("region", "eu")composes withAnd,OrandNot. Filters run on the publisher's thread for every message, so the interface also gives you one place to optimise later, for example by indexing equality filters by attribute value. - Observer, but only at the edge. The
Subscriberinterface is an observer; the dispatcher is what calls it. Keeping Observer on the far side of a buffer is the main difference from the Observer lesson's synchronous subject. - Producer and consumer inside each subscription: the broker produces into the bounded buffer and the dispatcher consumes. The Producer Consumer lesson covers the blocking variant; here the producer side never blocks, it applies a policy instead.
- Immutable value object for messages, so one allocation serves every subscriber and nobody can mutate a payload another subscriber is reading.
Argue against a Singleton broker: tests share state, and you will soon want two brokers (domain events and telemetry).
Concurrency and edge cases
- Slow subscriber. The defining case. With a shared queue, one handler that takes 200 ms per message drags every other subscriber to five messages per second. Per-subscription buffers isolate it; the overflow policy says what it loses.
- Reject must be atomic. If subscription C rejects after A and B already accepted, A and B have a message the publisher believes failed, and a retry delivers it twice. Check every matching subscription for room first, then enqueue into all of them, under one lock. Do not advance the sequence number on a rejected publish, or subscribers see a gap that never existed.
- Subscribe during publish. Iterate a snapshot of the subscription list (copy on write), so a subscriber added mid-publish either gets the whole message or none of it. A new subscription starts at the next sequence number; it never sees history in this design.
- Unsubscribe from inside a handler. The dispatcher checks a closed flag after each handler call and discards the rest of the batch.
- Handler throws. Catch it in the dispatcher, count it, and keep going, or the subscription dies silently. After N consecutive failures, park the subscription and raise an alert instead of spinning.
- Gap detection. After a drop, a subscriber sees sequence 7 then 10, and can resync from the source of truth.
Down the stack
Layer 1The contract
publish returns a sequence number once the message is in every matching buffer, or fails with back pressure and changes nothing. For each subscription, messages are delivered in increasing sequence order with no duplicates; gaps appear only where the overflow policy dropped a message, and the dropped counter accounts for every gap. No ordering holds across topics. Delivery is at most once: a crash loses whatever is buffered. Publish latency does not depend on any handler's speed.
Layer 2The mechanism
Each topic holds an array of subscriptions replaced on change (copy on write), so publish reads it without taking the subscription-set lock. Each subscription's buffer is a ring buffer: a fixed array of capacity slots and two counters, head and tail, where tail - head is the length and index = counter mod capacity. Drop-oldest advances head by one before writing; drop-newest just increments dropped. A dispatcher per subscription takes up to a batch of messages and calls the handler; Go programs often model this as one goroutine and a buffered channel per subscription, with a select and default branch to implement drop-newest without blocking.
Layer 3The machine
A message here is a pointer to an immutable object, so fan-out to 100 subscriptions writes 100 pointers (800 bytes on a 64-bit machine), not 100 payloads. A ring buffer slot write is a store into memory that is usually already in L1 or L2 cache, a few nanoseconds; an uncontended Go channel send is on the order of 50 to 100 ns because it takes the channel's internal lock. When a dispatcher sleeps on an empty buffer and is woken, the cost is a futex wake and a context switch, a few microseconds. That is why dispatchers drain in batches: one wake-up for 32 messages instead of 32 wake-ups. The head and tail counters are written by different threads; if they share a 64-byte cache line, every publish invalidates the dispatcher's copy and vice versa (false sharing), which is why high-throughput rings pad them onto separate lines. Garbage collection is the hidden cost of unbounded buffers: a backlog of a million small messages is a million live objects the collector must trace on every cycle.
Why it's built this way
- Subscribers run at different speeds, so each needs its own buffer, or the slowest sets the pace for everyone.
- Memory is finite, so buffers are bounded, which forces a decision about what to do when full.
- That decision depends on what the consumer values (freshness, completeness or cheapness), so it is a per-subscription strategy rather than a broker setting.
- Wake-ups cost microseconds while slot writes cost nanoseconds, so dispatchers batch, and publish never touches subscriber code.
- Fan-out multiplies every per-message cost by the subscriber count, so messages are shared immutable references and the routing step does no I/O.
Extensibility
- Durability. Replace the per-subscription ring with one append-only log per topic and a cursor per subscription. Drop policies become retention policies, and "dropped" becomes "the cursor fell behind the oldest retained offset". This is the step from this lesson to the broker in Design Messaging Queue.
- At-least-once delivery. Add
ack(seq)and an in-flight set with a redelivery deadline from the injected clock. Handlers must then be idempotent; see the Idempotency lesson. - Wildcards. Swap the topic map for a trie keyed by topic levels so
orders/+/createdmatches in O(levels). Only the routing step changes. - Shared subscriptions. Several workers attach to one subscription and compete for its messages. The buffer stays the same; the dispatcher becomes a pool.
Leadership lens
- Build first: the per-subscription buffer with the overflow policies and a test where one handler sleeps while another must keep receiving. That test is the whole value of the component.
- Ask in review: what happens when a subscriber is ten times slower than the publish rate; which policy does each production subscription use and who chose it; is the dropped counter on a dashboard with an alert.
- If events cross team boundaries, version the payload schema and treat a topic like an API; a rename otherwise breaks another team's handler with no compiler error.
- Resist growing this into a durable broker in-house. When requirements say "survive restarts" or "across services", adopt Kafka, NATS or a cloud service and keep this design for in-process fan-out.
Check yourself
Why is a loop over subscriber callbacks inside
publishnot a pub/sub system, even though it decouples the publisher from subscriber types?Show answer
The publisher's thread runs every handler, so a slow or throwing handler delays or breaks the publisher and every later subscriber. Pub/sub puts a buffer and a separate dispatcher between them.
Subscription C uses the reject policy and is full. A and B have room. What must
publishdo, and why?Show answer
Enqueue nowhere and fail, without consuming a sequence number. Partial delivery would make a publisher retry duplicate the message for A and B and leave a phantom gap for C.
A stock-ticker display and an audit logger subscribe to the same topic. Which overflow policy suits each?
Show answer
The ticker wants drop-oldest, because only the newest price matters. The audit logger needs reject (or a durable log), because losing an event silently is worse than slowing the publisher.
Why do dispatchers drain buffers in batches rather than one message at a time?
Show answer
Waking a sleeping thread costs a futex call and a context switch, microseconds, while taking a message from a ring costs nanoseconds. Batching amortises one wake-up across many messages.
Watch
- What is the Publisher Subscriber Model? (Gaurav Sen): why systems move from direct calls to pub/sub, and what that decoupling costs.
- Observer Pattern, Design Patterns ep 2 (Christopher Okhravi): the synchronous pattern this design wraps behind a buffer.
Free resources
- LMAX Disruptor technical paper: ring buffers, sequence cursors, batching and false sharing in a high-throughput in-process event system.
- Observer (Game Programming Patterns, Robert Nystrom): the costs of synchronous dispatch that motivate putting a queue between publisher and subscriber.
- Event Queue (Game Programming Patterns, Robert Nystrom): decoupling when an event is handled from when it is sent, with ring buffers and feedback loops.
- Google Cloud Pub/Sub subscriptions: how a managed service models subscriptions, filters and acknowledgement, for comparison with the in-process core.
- Amazon SNS message filtering: attribute filter policies, the production version of this lesson's filter.