The problem: one event, many independent reactions#
The naive version is a direct call. The order service calls billing, then calls analytics, then calls email. This breaks in three ways at once. It is slow, because the order service waits for all three before it can respond. It is fragile, because if the email service is down the whole order fails. And it is rigid, because adding a fourth consumer means editing and redeploying the order service.
What we actually want is for the order service to state a fact once, "order 4271 was placed," and be done. Whoever cares about that fact can react to it, in their own time, without the order service knowing they exist. If one reactor is slow or broken, the others should be unaffected, and the broken one should get another chance later rather than losing the event.
Publish/subscribe is the decoupling that gives you exactly this. A producer publishes messages to a named topic. Interested consumers attach a subscription to that topic. A broker in the middle copies every published message to every subscription and then tracks, per subscription, whether that copy has been handled yet. The producer and the consumers never reference each other. They only reference the topic.
The mental model: a topic is a loudspeaker, a subscription is a mailbox#
Picture the topic as a loudspeaker in a room. When the producer speaks, everyone with a mailbox on the wall gets a written copy dropped into their own box. The speaker does not wait for anyone to read it, and does not even look up to see who is in the room.
Each mailbox is a subscription. The copy sits in the mailbox until its owner picks it up, works through it, and signs a receipt. If the owner drops the copy or never signs, the broker eventually notices and drops a fresh copy in the same box. Crucially, one owner signing their receipt does nothing to anyone else's mailbox. Every mailbox has its own copy and its own pace. That independence is the whole point of the pattern, and it is what a plain shared queue cannot give you.
How it works: fan-out and the delivery lifecycle#
There are two mechanisms stacked on top of each other. The first is fan-out: turning one publish into one copy per subscription. The second is the delivery lifecycle: how the broker walks a single copy from arrival to done, with retries in between.
When the producer publishes a message, the broker assigns it an id, records it on the topic once, and then places an independent copy into every subscription's pending set. A subscription with nothing attached to it still gets its copy; a subscription added tomorrow simply starts receiving copies of messages published from then on. Fan-out is why the same event can drive billing, analytics, and email without any of them knowing about the others.
Now follow a single copy inside one subscription. It starts pending, meaning it has arrived but nobody is working on it yet. When a consumer pulls it, the copy becomes in-flight: delivered, but not yet confirmed. The broker starts an acknowledgement deadline the moment it hands the copy over. From here, three things can happen.
- The consumer finishes the work and acknowledges the copy. The broker marks that copy done and never delivers it to this subscription again. This closes the copy for one subscription only; the message stays open on every other subscription until each one acks its own copy.
- The consumer reports failure with a negative acknowledgement (a nack). The broker puts the copy back into the pending set for redelivery, and remembers that this copy has now been delivered once.
- The consumer says nothing at all, because it crashed or hung. When the acknowledgement deadline passes, the broker treats the silence exactly like a nack: it assumes the delivery failed and returns the copy to pending.
A redelivered copy goes around the loop again: pending, then in-flight on the next pull, then ack or fail. Every failure increments a delivery counter for that copy. Once the counter passes a configured retry budget, the broker stops retrying and moves the copy to a dead-letter queue, a side channel where poison messages wait for a human or a separate handler to inspect them. Without a dead-letter queue a permanently failing message would redeliver forever and block progress.
publish
|
v
[pending] <-------------------+
| | nack / timeout
| deliver | (budget left)
v |
[in-flight] --- ack ---> [acked] (done, never redelivered here)
|
| nack / timeout, budget spent
v
[dead-letter] (parked for inspection)The engine on the simulator page is exactly this state machine. Publish a message and watch a copy land in every subscription. Deliver one, let its ack deadline lapse, and watch the same copy get delivered a second time. Nack a copy until its budget runs out and watch it fall into dead-letter, while the other subscription drains cleanly beside it.
At-least-once delivery and why you must be idempotent#
Look closely at the redelivery rule and a consequence falls out. The broker redelivers whenever it is unsure a copy was handled, and a missed ack looks identical whether the consumer crashed before doing the work or crashed just after doing it but before sending the ack. The broker cannot tell those apart, so it errs on the side of delivering again. This is at-least-once delivery: a message is delivered one or more times, and under failures the count is sometimes more than one.
That means a consumer can genuinely process the same message twice. If the handler charges a credit card, a duplicate delivery is a duplicate charge. The fix is not to make the broker perfect, because a broker that guarantees exactly-once across crashes is either very expensive or subtly lying. The fix is to make the handler idempotent: handling the message twice must land the same result as handling it once. In practice you give each message a stable id and record the ids you have already applied, so a repeat is recognised and skipped.
PredictA consumer pulls a message, charges the card successfully, then crashes before its acknowledgement reaches the broker. What happens next, and what should the handler have done?
Hint: The broker cannot tell 'crashed before the work' from 'crashed after the work but before the ack'.
The acknowledgement deadline passes, the broker sees no ack, and it redelivers the same message to a replacement consumer, which charges the card a second time. The broker did nothing wrong; this is at-least-once delivery working as designed. The handler should have been idempotent: record the order id as charged inside the same transaction as the charge, so the redelivery sees the id already applied and skips it.
The opposite setting exists too. At-most-once delivery acks the message before doing the work, so a crash loses it and it is never retried. That trades duplicates for lost messages, which is fine for a metric you can afford to drop and wrong for a payment. Most durable pub/sub systems default to at-least-once because losing a message is usually worse than handling one twice, and idempotency is a tractable thing to build.
What it costs: storage, amplification, and the ack window#
Pub/sub does not have an interesting big-O; its costs are about storage and multiplicity. The first cost is fan-out storage. The broker must track delivery state per subscription, so a single message with N subscriptions produces N pieces of bookkeeping, and each subscription retains its copy until it acks. A topic with ten subscriptions and one slow consumer keeps that consumer's entire backlog resident while the other nine are empty.
The second cost is delivery amplification. Every redelivery is another delivery event, so a subscription with flaky consumers can deliver far more times than the publish count. The gap between total deliveries and total publishes is a direct read on how much retrying is happening, and a large gap is a signal that a consumer is failing rather than that traffic is high.
The third cost is the acknowledgement window. While a copy is in-flight it is invisible to other pulls on that subscription, so a long ack deadline protects slow work from spurious redelivery but also lengthens how long a truly-crashed consumer's message sits stuck. A short deadline recovers from crashes fast but risks redelivering work that was merely slow. Tuning this window is the main knob operators actually turn.
| Quantity | Roughly | Why it matters |
|---|---|---|
| Bookkeeping per message | O(subscriptions) | Each subscription tracks its own copy independently |
| Total deliveries | publishes + redeliveries | Redeliveries amplify work; the excess signals failing consumers |
| In-flight window | ack deadline per copy | Too short redelivers slow work; too long strands crashed work |
| Retry budget | small constant (often 5 to 10) | Bounds retries so a poison message dead-letters instead of looping |
Trade-offs: the dials you actually set#
- Delivery guarantee. At-least-once (duplicates possible, nothing lost) is the durable default. At-most-once (no duplicates, loss possible) suits droppable data. Exactly-once is offered by some systems within a bounded scope, but it is costly and never free across arbitrary side effects, so idempotency remains the honest answer.
- Push versus pull. In pull mode consumers ask for messages when they have capacity, which gives them natural backpressure. In push mode the broker delivers to an endpoint, which is simpler but can overwhelm a slow consumer unless the broker also throttles.
- Ack deadline length. Longer deadlines tolerate slow work and reduce spurious redelivery; shorter deadlines recover from crashes faster. Systems let you extend the deadline for a message you are still working on rather than force one fixed value.
- Retry budget and dead-lettering. A generous budget survives transient hiccups; a tight budget quarantines poison messages sooner. Set it with a dead-letter queue attached so exhausted messages are inspected, not silently dropped.
- Ordering. Strict per-key ordering limits how much you can process in parallel, because you cannot advance past a stuck message in that key. Most pub/sub systems make ordering opt-in for this reason.
How it compares: pub/sub, work queue, and log#
Three patterns are easy to confuse because all three move messages from producers to consumers. The difference is who receives a given message and how progress is tracked.
| Pattern | Who gets a message | How progress is tracked | Redelivery |
|---|---|---|---|
| Pub/sub (this page) | Every subscription gets its own copy | Per-message state per subscription (pending / in-flight / acked / dead) | Per copy, on nack or ack timeout |
| Work queue (competing consumers) | Exactly one consumer in the pool | Per-message visibility while a worker holds it | On visibility timeout or nack |
| Log (Kafka-style) | Every consumer group reads the same log | A single committed offset per group, a cursor into the log | By resetting the offset backwards |
The log is the closest cousin and the sharpest contrast. A log tracks a group's progress with one number, the committed offset, which is a bookmark that only moves forward. That number cannot express "message 5 is done but message 4 is still being retried," because an offset commits a contiguous prefix. Pub/sub tracks each message's state separately, so out-of-order acks and per-message redelivery are natural. The log wins on raw throughput and replay; pub/sub wins on per-message delivery semantics and independent per-consumer retries. Kafka's own internals (its partitioned, replicated log) are covered separately; this page is deliberately the delivery-semantics layer that sits above such a log.
In the real world
The pattern shows up under many names, but the lifecycle is the same one modeled here.
- Google Cloud Pub/Sub is the pattern almost verbatim: a topic, many subscriptions, at-least-once delivery, an acknowledgement deadline (10 seconds by default, extendable up to 10 minutes), and a dead-letter topic after a configurable maximum delivery attempts, which defaults to 5 and can range up to 100. Its delivery-attempt count is defined as one plus the number of nacks and ack-deadline expirations, exactly the counter used here.
- AWS models fan-out by pairing two services: an SNS topic fans a message out to several SQS queues, and each SQS queue is then a work queue for one consumer group. SQS uses a visibility timeout (30 seconds by default, up to 12 hours) as its ack window, and moves a message to a dead-letter queue once its receive count passes maxReceiveCount. AWS states plainly that SQS is at-least-once and that consumers must tolerate duplicates.
- RabbitMQ separates the exchange (the fan-out router) from queues (the per-consumer mailboxes), and uses consumer acknowledgements with requeue-on-nack in the same shape.
- Redis pub/sub is the deliberate opposite: it is fire-and-forget with no persistence, no acks, and no redelivery, so a subscriber that is offline when a message is published simply misses it. It is a useful reminder that the acking machinery above is exactly what buys you durability.
Common pitfalls
- Assuming exactly-once. The default is at-least-once, so a non-idempotent handler will eventually double-charge, double-email, or double-count. Design for duplicates from day one.
- An ack deadline that is too short. If real processing sometimes takes longer than the deadline, the broker redelivers work that was merely slow, and you get duplicates and wasted effort under load. Extend the deadline for long jobs instead of shortening it for everyone.
- No dead-letter queue. Without one, a message that always fails redelivers forever, burns capacity, and can stall a subscription behind it. Always attach a dead-letter queue and alert on it.
- Acking before the work is done. Acking on receipt turns at-least-once into at-most-once by accident, and a crash then silently loses the message. Ack only after the work is durably committed.
- Expecting order without asking for it. Fan-out and redelivery mean copies can arrive out of order across subscriptions and even within one. If you need order, turn on ordering explicitly and accept the reduced parallelism.
- Letting one slow subscription hide a problem. Because subscriptions are independent, a backed-up one does not slow the others, which is good, but it also means its growing backlog is easy to miss. Monitor per-subscription in-flight and backlog, not just topic-wide totals.
In an interview
If you are asked to design a system where one event triggers several independent reactions (an order fanning into billing, analytics, and notifications, say), reach for pub/sub and be ready to defend the delivery semantics.
- Lead with the decoupling: the producer publishes once to a topic and does not know its consumers, so you add or remove reactors without touching the producer.
- Explain fan-out plus per-subscription state: each subscription gets its own copy and tracks it independently, so a slow or failing consumer never blocks the others.
- State the guarantee out loud. Say at-least-once, then immediately say idempotency, because the interviewer is listening for whether you know duplicates are possible.
- Mention the ack deadline and the dead-letter queue as the two operational knobs, and be able to say what each does under a crash and under a poison message.
- Contrast with a log when pushed: an offset commits a contiguous prefix and cannot express per-message retries, whereas pub/sub tracks each message separately. That is the crisp reason to pick one over the other.
Is pub/sub the same as a message queue?
No. A plain work queue delivers each message to exactly one consumer in a pool (competing consumers). Pub/sub delivers a copy of each message to every subscription. The AWS pattern of an SNS topic feeding several SQS queues is literally pub/sub fan-out (SNS) composed with per-queue work queues (SQS).
If a message is acked on one subscription, do the others still get it?
Yes. An ack closes only that subscription's copy. Every other subscription keeps its own copy and its own progress until it acks, nacks, or dead-letters it. That independence is the defining property of the pattern.
Can I get exactly-once delivery?
Some systems offer a bounded exactly-once mode, but it is expensive and cannot cover arbitrary external side effects like a third-party charge. The durable, honest answer is at-least-once delivery plus idempotent handlers keyed on a stable message id.
What ends up in the dead-letter queue?
A message copy that failed (via nacks or ack-deadline timeouts) more times than the retry budget allows. It is parked there so a human or a separate handler can inspect it, instead of retrying forever and blocking the subscription.
Summary#
TL;DRthe 30-second version
- A producer publishes a message once to a topic; the broker copies it into every subscription. The producer never knows its consumers.
- Each subscription tracks its own copy through pending, in-flight (delivered but unacked), acked, or dead-lettered. Progress is independent per subscription.
- An ack closes a copy for one subscription only. A nack or a missed-ack timeout returns the copy for redelivery; past the retry budget it is dead-lettered.
- Delivery is at-least-once, so a consumer can see the same message more than once. Handlers must be idempotent, keyed on a stable message id.
- The two operational knobs are the acknowledgement deadline (the in-flight window) and the retry budget plus its dead-letter queue.
- A log tracks a group's progress with one forward-only offset; pub/sub tracks each message separately, which is what makes per-message retries and out-of-order acks possible.
References
- Google Cloud Pub/Sub — Subscription properties (ack deadline, retry) — Default acknowledgement deadline and the delivery model.
- Google Cloud Pub/Sub — Dead-letter topics — Maximum delivery attempts (default 5, up to 100) and how the delivery counter is defined.
- Amazon SQS — Visibility timeout — The ack window in SQS: 30 seconds by default, up to 12 hours.
- Amazon SQS — Dead-letter queues — maxReceiveCount, the at-least-once model, and moving failed messages to a DLQ.
- AWS — Fanout to Amazon SQS queues (SNS + SQS) — The canonical fan-out pattern: one SNS topic to many SQS queues.
- RabbitMQ — Consumer Acknowledgements and Publisher Confirms — Acks, nacks, requeue, and redelivery in a broker with exchanges and queues.
Ready to try it?
The simulator is a real, deterministic implementation — pick a scenario and step through it, scrubbing the timeline forward and backward through every change.