all_lessons / system_design / 11 · asynchronous messaging lesson 11 / 19

Asynchronous messaging & streaming

Taking work off the synchronous critical path. A queue is a shock absorber, a buffer, and a decoupler — and it costs you eventual consistency, an extra moving part, and a lifetime of reasoning about duplicates.

First principle

A synchronous call couples caller and callee in time. The caller blocks until the callee answers, so a slow or dead callee directly degrades the caller — the tail-latency coupling from lessons 02 and 07. If A calls B calls C synchronously, A's p99 is the sum of three tails and A's availability is the product of three availabilities.

A message broker breaks that coupling. The producer hands off a message and returns immediately; the consumer processes it whenever it can. The two sides no longer have to be up at the same time, fast at the same time, or scaled to the same size. That is the entire value proposition — and everything else in this lesson is a consequence of that one move.

Why decouple: what a queue buys and what it costs

Putting a buffer between producer and consumer is a classic engineering trade. Be able to recite both columns:

You buyYou pay
Spike absorption — a 10× burst fills the buffer instead of overwhelming the consumer; the queue smooths bursty arrivals into steady drain.Eventual consistency (09) — the result isn't ready when the API returns. "Your video is processing."
Independent scaling — add consumers without touching producers; scale the slow side only.Extra component — the broker is now a dependency that can fail, lag, fill its disk, or lose data.
Resilience — consumer down ≠ producer down. Work waits in the queue and drains on recovery.End-to-end latency now includes queue wait — under load, time-in-queue can dominate (lesson 02's queueing term).
Fan-out — one event, many independent consumers (search index, email, analytics) with no producer changes.Ordering & duplicates get subtle — global order is gone; you must design for redelivery.

The decision rule. Keep the user-blocking path short: validate the request, persist the minimum, enqueue, respond. Do everything derived asynchronously — resize the image, transcode the video, send the email, update the search index, fan out the post to followers, recompute the recommendation. If the answer is "the user does not need to see the result of this work to consider their action done," it belongs off the critical path.

SYNCHRONOUS (coupled in time) ASYNCHRONOUS (decoupled) ----------------------------- ------------------------ client --POST--> API ---> resize ---> email client --POST--> API --(enqueue)--> 202 Accepted (blocks ~3s) (slow) (slow) (blocks ~30ms) | p99 = sum of every hop's tail v one slow hop = slow user [ broker / log ] --> resize worker callee down = caller errors \-> email worker \-> index worker user latency = enqueue only; heavy work drains in background

Two broker shapes: queue vs. log

The single most important distinction in this space. They look similar (producers, consumers, messages) but the data model is opposite, and that drives every other property.

Queue / message brokerLog / stream
ExamplesRabbitMQ, ActiveMQ, AWS SQSApache Kafka, AWS Kinesis, Pulsar, Redpanda
Delivery modelA message goes to one of N competing consumers, then is removedAn append-only ordered log; messages are retained, consumers read by offset (the consumer's bookmark — how far into the log it has read)
After consumptionGone (acked & deleted)Still there until retention expires — replayable
Multiple subscribersHard — each message is consumed once; need separate queues / exchanges to copyNative — each consumer group (a set of consumers that split a topic's partitions so each message is handled once by the group) reads the whole stream independently at its own offset
Scaling parallelismAdd consumers; broker load-balances messages across themAdd consumers up to partition count (the hard ceiling)
OrderingBest-effort / per-queue; weak once you paralleliseStrict within a partition; none across partitions
Best forDistributing tasks/work — job queues, RPC offload, "do this once"Event streaming, replay, reprocessing, multiple subscribers, audit log

Mental model: a queue is a to-do list — items are pulled off and disappear. A log is a ledger / journal — entries are appended and stay, and any reader can scan from any point. Pub/sub (one publish → many subscribers) is the fan-out pattern; a log gives it to you for free via consumer groups, while a classic broker implements it with a topic-exchange that copies the message into each subscriber's queue.

Why a log is so fast: a log broker like Kafka stores each partition as an append-only commit log on disk — the same write-optimised, sequential-append shape that makes an LSM-tree's (04) writes cheap. Because every write goes to the end of a file rather than seeking to a random spot, the disk does sequential I/O — and sequential writes are an order of magnitude faster than random ones, even on SSDs. That single physical property is why a log sustains such high throughput while still being durable on disk.

Choosing between them

The scaling & ordering model (log)

This is partitioning (lesson 07) wearing a different hat. A topic is split into partitions. A partition is simultaneously:

topic "orders" (3 partitions, retained log; numbers = offsets) P0: 0 1 2 3 4 5 ... <-- consumer A (group "billing") P1: 0 1 2 3 ... <-- consumer B (group "billing") P2: 0 1 2 3 4 ... <-- consumer C (group "billing") group "billing" max parallelism = 3 key=user42 --hash--> always P1 => every user42 event is in P1, in order another group "search-index" reads ALL three partitions at its own offsets, independently

Key-based partitioning routes all messages with the same key (e.g. user_id) to the same partition via hash(key) % partitions, so per-key order is preserved even though global order is not. This is usually exactly what you want: you don't care that user A's events interleave with user B's, only that each user's own events stay in order.

The trade: more partitions = more parallelism and throughput, but weaker effective ordering (more independent streams), more open files / replication overhead, slower rebalances, and — note — you generally can't reduce partition count later without breaking key→partition stability. Size for ~1–2× your projected peak consumer count, not 1000× "just in case."

Delivery semantics — the classic interview trap

Ask any candidate "how do you get exactly-once?" and watch what happens. Here is the honest table:

SemanticHowFailure behaviourUse when
At-most-onceAck before processing (fire-and-forget)May lose messages on crash; never duplicatesMetrics, telemetry, logs — a dropped data point is fine
At-least-onceAck after processing; redeliver on timeout/failureNever loses; produces duplicates (crash between side-effect and ack → reprocess)The practical default. Almost everything that matters
"Exactly-once"Not deliverable across a network. Achieved as at-least-once + idempotent consumer / dedup key, or transactionally within one systemEffect happens once even though delivery may repeatPayments, inventory, anything where a duplicate is harmful

The line to deliver: "Exactly-once delivery is a myth — two generals; you can't atomically commit a network send and a remote side-effect. Exactly-once processing is the real goal, and you get it with at-least-once delivery plus an idempotent consumer." Idempotency means: include a stable message_id / dedup key (the same idea as the Idempotency-Key request header in 03), and on the consumer either (a) check-and-set against a dedup store before applying, (b) use an idempotent operation (SET not INCR, upsert by key), or (c) bind the side-effect and the offset commit in one transaction. Kafka's "exactly-once" is precisely this — atomic produce + offset-commit within Kafka; it does not extend to your external database or email provider. The dedup-store mechanics and the inbox/outbox patterns that make this crash-safe are the whole subject of 12.

Worked example — why at-least-once duplicates are routine, not rare

A consumer pulls a message, charges a card (the side-effect), then sends the ack. Suppose processing takes 200 ms and the consumer crashes uniformly at random during processing with probability 0.1% per message (deploys, OOMs, node loss).

The dangerous window is "side-effect done, ack not yet sent." If the side-effect lands at, say, 150 ms of the 200 ms and the crash falls in the final 50 ms, the broker never sees an ack, its visibility timeout fires, and it redelivers — a second charge.

P(dup per message) ≈ P(crash) × (vulnerable fraction) = 0.001 × (50ms / 200ms) = 0.00025

At 2,000 msg/s that is 0.00025 × 2000 = 0.5 double-charges per second — ~43,000 per day. "Rare" crashes produce a steady stream of duplicates at scale. Conclusion: you do not hope duplicates won't happen; you make the charge idempotent (dedup on payment_id) so a redelivery is a no-op. The math is why "we'll just be careful" is not an answer.

Operational realities: backpressure, lag, and poison messages

A queue is a buffer, and buffers have a failure mode: they hide a permanently-too-slow consumer until they explode.

The trap: an unbounded queue masking a too-slow consumer

If consumer capacity is even 1 msg/s below arrival rate, the backlog grows without bound and time-in-queue → ∞ — the queue "works" (no errors) while the data gets arbitrarily stale. A queue buys time to absorb spikes; it cannot fix a sustained capacity deficit. The fix is always more consumers (up to partition count), more partitions, a faster consumer, or shedding load — never "make the buffer bigger."

Backpressure simulator

The arithmetic that decides whether a queue is a shock absorber or a time bomb. Capacity is C × perConsumer, capped by partitions; the net drain rate is capacity − λ.

Will the queue keep up — and how fast does a backlog drain?
Set producer rate λ, per-consumer throughput, consumer count C (capped by partitions), and a spike backlog M. Watch capacity vs. λ and the time to drain.
effective consumers (min C, partitions)
total capacity (msg/s)
net drain = capacity − λ (msg/s)
time to drain spike M
Status

Interview prompts you should be ready for

  1. When would you put a queue between two services, and when would a synchronous call be better? (senior answer: queue when the work is derived/deferrable and you want spike absorption, independent scaling, or fan-out, and the caller doesn't need the result inline. Stay synchronous when the caller needs the answer to proceed, or when the added eventual-consistency + extra-component cost isn't justified — don't queue a read that must return data.)
  2. Kafka vs. SQS/RabbitMQ — pick one and defend it. (senior answer: log vs. queue. Kafka if you need replay, multiple independent consumer groups, per-key ordering at scale, or an audit/event-sourcing stream. A task queue if it's "run this job once," no replay, single reader — you get visibility timeouts and DLQs with far less operational weight.)
  3. How does ordering work in Kafka, and how many consumers can actually run in parallel? (senior answer: ordering is per-partition only, never global; one partition → at most one consumer per group, so max parallelism = partition count. Key-based partitioning gives per-key order. The eleventh consumer on a ten-partition topic is idle.)
  4. Can you guarantee exactly-once delivery? (senior answer: no — exactly-once delivery across a network is a myth; you can't atomically commit a send and a remote side-effect. Use at-least-once + idempotent consumer (dedup key, upsert, or transactional offset commit) to get exactly-once processing. Kafka's "exactly-once" is internal to Kafka and doesn't cover your external DB.)
  5. Your consumer just got a 10× duplicate redelivery storm. Why, and how is your system unharmed? (senior answer: at-least-once redelivers when an ack is lost — rebalance, timeout, crash between side-effect and ack. Unharmed because every handler is idempotent: dedup on a stable message_id, or operations are naturally idempotent (set/upsert), so reprocessing is a no-op.)
  6. Consumer lag is climbing steadily. Walk me through diagnosis and fixes. (senior answer: rising lag = capacity < arrival. Check if all partitions have a consumer (idle members mean you're partition-capped → add partitions); check per-message latency for a slow dependency or a poison message stuck on a partition; then scale consumers up to partition count, speed up the handler, or shed load. Making the buffer bigger fixes nothing.)
  7. What's a dead-letter queue and why does it matter for a partitioned log? (senior answer: a poison message that always fails gets redelivered forever and blocks its partition's offset, stalling everything behind it. After N retries, route it to a DLQ so the partition advances; alert on DLQ depth and inspect offline.)
  8. How do you apply backpressure when producers outrun consumers? (senior answer: bounded queue with shed/reject (429) or producer flow control — propagate the pressure to the source. The anti-pattern is an unbounded buffer that converts an over-capacity condition into silently rising latency. A queue absorbs spikes; it cannot fix a sustained deficit.)
Takeaway

A broker decouples producer and callee in time — that's the whole game, and everything else is a consequence. Choose a queue for "do this task once," a log for replayable, multi-subscriber, per-key-ordered streams. Remember the ceilings: in a log, max parallelism = partition count and order is per-partition only. Assume at-least-once and make consumers idempotent — exactly-once delivery is a myth, exactly-once processing is the goal. And watch lag: a queue absorbs spikes but cannot fix a sustained capacity deficit; an unbounded buffer just hides the failure until latency goes to ∞.