Skip to the notes
JSGroundwork
JSGroundwork handwritten · web dev
✎Playground→⌘Problems↻Review🔥Progress

Chapters

24 chapters
⌕
Beginner7›
B1The mental modelB2Client-server basicsB3Vertical vs horizontalB4Databases in designB5Caching fundamentalsB6APIs & communicationB7Walkthrough: URL shortener
Intermediate9›
I1Load balancing in depthI2CDNsI3Message queues & asyncI4Consistency modelsI5Rate limiting & throttlingI6Designing for availabilityI7Storage systemsI8Search systemsI9Walkthrough: feed/chat
Advanced8›
A1CAP theorem in depthA2Sharding at scaleA3Distributed consensusA4Fault toleranceA5Observability at scaleA6Capacity estimationA7Case studiesA8Tradeoff thinking
/ search[ ] chaptert top

System Design levels

1Beginner2Intermediate3Advanced

Ready to read

JSJavaScript⑂Git◎Interview prepΣDSA in JSSDSystem Design
More topics15›
</>HTML{ }CSS⚛ReactNNext.jsNeNest.jsTSTypeScriptNoNode.js🐳DockerDBSQL & Databases✓Testing🔒Web Security☁Cloud & DevOps◈GraphQL◆Redis☸Kubernetes
100%
I3

Message queues & async processing

The request path should only do the work the user is actually waiting for — everything else goes on a queue and gets retried.

Decoupling: what you actually buy

Synchronous calls couple two things that have no business being coupled: the latency a user experiences and the capacity of the slowest downstream service. Put a durable buffer between producer and consumer and you break that link. Four distinct wins come out of it, and naming them separately is what makes you sound like you have run this in production.

  • Spike absorption. A queue turns a 20x traffic burst into a growing backlog instead of a cascade of timeouts. Your workers keep running at their sustainable rate; the queue eats the difference. Latency degrades gracefully rather than the system falling over.
  • Latency shedding. Image transcoding takes 4 s; sending an email takes 800 ms; reindexing takes 300 ms. None of it needs to happen before the HTTP response. Enqueue in ~2 ms and return 202.
  • Retries with durability. If the email provider is down, an in-process retry loop dies with the pod. A queued message survives the deploy, the crash and the region failover.
  • Fan-out. One "order placed" event feeds billing, search indexing, the recommendation model, the fraud pipeline and the data warehouse — and adding a seventh consumer needs no change to the producer.
POST /upload synchronous thumbnail 4 s email 800 ms index 300 ms user waits 5.1 s; one slow provider makes every upload time out POST /upload enqueue, 2 ms durable queue worker pool thumbnailer mailer 202 Accepted in 12 ms; a dead mail provider grows a backlog instead of an outage
The queue does not make the work faster. It makes the work not the user's problem, and it makes failure recoverable instead of lost.
⚠ Async changes the product, not just the architecture The moment you return 202 you owe the user a way to observe completion: a status endpoint, a websocket push, an email, or an optimistic UI that reconciles later. Candidates who move work off the request path without saying how the client learns it finished get marked down — the interviewer is checking whether you understand you just introduced a distributed state machine into the product.

Queue vs log: RabbitMQ and Kafka are not the same shape

A queue broker (RabbitMQ, SQS, ActiveMQ) tracks per-message state. A message is delivered, acknowledged, and deleted. The broker owns the bookkeeping, which buys you per-message acking, arbitrary redelivery delays and routing topologies — at the cost of holding mutable state per message.

A log (Kafka, Pulsar, Kinesis, Redpanda) is an append-only file per partition. Consumers hold an offset — a number — and the broker deletes nothing until the retention window expires. Reads are sequential disk scans, which is why a single broker sustains hundreds of MB/s. Because messages aren't deleted on read, you can rewind the offset and replay a week of history into a new consumer, which is the capability queues fundamentally lack.

producer key = userId hash % 3 partition 0 partition 1 partition 2 consumer A owns P0 + P1 consumer B owns P2 consumer C idle — no partition one partition has exactly one owner in a group, so partition count is your parallelism ceiling
Consumer C is the whole lesson: you scale a Kafka consumer group by adding partitions, not by adding pods. Pick the partition count for the parallelism you'll want in a year.
 Queue broker (RabbitMQ, SQS)Log (Kafka, Pulsar)
Unit of progressPer-message ack / nackPer-partition offset commit
After a message is consumedDeletedStill there until retention expires (hours to forever)
ReplayNot possible — you must have kept a copy yourselfSeek the offset backwards; a brand-new consumer can read all history
OrderingPer-queue, and lost the moment you add a second consumerTotal order within a partition; none across partitions
ParallelismAdd consumers freely — the broker load-balances messagesCapped at partition count per consumer group
Throughput, single node~20-50 k msg/s (much lower with persistence + per-message routing)Hundreds of MB/s; ~10 MB/s per partition is a comfortable planning figure
RoutingRich: topic/fanout/header exchanges, per-message TTL, delayed deliveryDeliberately dumb: topic + partition. Routing is the consumer's job
Reach for this whenTask/job semantics — "someone do this one thing", varied per-message delays, complex routing, modest volumeEvent-stream semantics — many independent consumers of the same events, replay for backfills or new services, ordered per-entity change streams, high volume

A cheap tell that you've thought about it: "SQS standard gives me unbounded throughput but no ordering; SQS FIFO gives me ordering per message-group at ~300 messages/second per group (3,000 batched), which is fine per user and useless as a global pipe." Named limits beat adjectives.

Delivery semantics, done properly

Every distributed queue makes the same unavoidable choice: when the consumer processes a message and then dies before acknowledging it, was the message delivered? You can only pick which side of that ambiguity you fail on.

SemanticMechanismFailure modeUse it for
At-most-onceAck (or commit the offset) before processingCrash after ack, before work → message silently lostHigh-volume telemetry, click logs, metrics — where one lost sample is genuinely irrelevant and throughput is everything
At-least-onceAck after processing succeedsCrash after work, before ack → message redelivered and processed twiceEssentially everything. This is the default in Kafka, SQS and RabbitMQ, and the sane baseline
"Exactly-once"At-least-once delivery + an idempotent consumer, or a transaction spanning input and outputOnly holds inside the transactional boundary — the moment you call an external API it degrades to at-least-oncePayments, ledgers, anything where a duplicate is a customer-visible error
Exactly-once delivery does not exist; exactly-once effect does Two processes, an unreliable network, and no way to distinguish "your ack was lost" from "you never received it" — the sender must either resend (risking a duplicate) or not (risking a loss). This is a proof, not an engineering limitation. What you can build is an at-least-once pipeline whose side effects are idempotent, so processing a message twice produces the same state as processing it once. Kafka's exactly-once mode is exactly this, mechanised: an idempotent producer that de-duplicates by sequence number, plus a transaction that commits output records and input offsets atomically — and it only holds while you stay inside Kafka.

So the real engineering question is never "which semantic do I pick?" It is "what makes my consumer idempotent?" Three answers, in descending order of how often they're the right one:

  • A natural idempotency key. The message carries a stable ID (order ID, event ID). The consumer writes INSERT ... ON CONFLICT DO NOTHING into a processed-events table inside the same transaction as its business write. Second delivery hits the conflict and does nothing. Cheap, obvious, correct.
  • Naturally idempotent operations. SET status = 'shipped' is safe to repeat; balance = balance + 10 is not. Prefer absolute writes over deltas when you get to choose the schema — this single habit removes most duplicate-processing bugs before they exist.
  • Idempotency tokens at the boundary. When the side effect leaves your system (Stripe charge, email send), pass a client-generated key that the provider de-duplicates on. Stripe's Idempotency-Key exists for precisely this scenario.
Say it like this → "I'll run at-least-once and make the consumer idempotent, because exactly-once delivery isn't achievable across a network — it's at-least-once plus de-duplication. I'll put a unique event ID on every message and have the consumer insert it into a processed-events table in the same transaction as the business write, so a redelivery is a no-op rather than a double charge."

Retries, dead letters and poison messages

At-least-once means redelivery, and redelivery means you need a policy for messages that will never succeed. A poison message — malformed payload, a referenced row that was deleted, a bug that only triggers on one record — will otherwise be retried forever, and in a Kafka partition it blocks every message behind it. That is head-of-line blocking, and it is how one bad record stops a pipeline for an entire key range.

  • Bound the retries. 3-5 attempts with exponential backoff and jitter (see the rate-limiting chapter — the same backoff maths applies). Unbounded retry against a struggling downstream is a self-inflicted DDoS, and synchronised retries are worse than no retries.
  • Distinguish retryable from terminal. A 503 or a connection reset deserves a retry. A 400, a schema violation or a foreign-key error will fail identically at attempt 50 — route it straight to the DLQ and skip the backoff ladder entirely.
  • Dead-letter queue. After the budget is exhausted, move the message — with the original payload, the error, the attempt count and a trace ID — to a DLQ. Then alert on DLQ depth. An unmonitored DLQ is a data-loss mechanism with extra steps; the DLQ's value is entirely in someone looking at it.
  • Make the DLQ replayable. Fix the bug, ship it, drain the DLQ back into the main queue. If replay requires a bespoke script written under incident pressure, you don't have a DLQ, you have a graveyard.
  • In Kafka, sidestep head-of-line blocking by publishing the failure to a retry topic and committing the offset, so the partition keeps moving. You trade strict ordering for liveness, deliberately.

Backpressure: the queue is a shock absorber, not a landfill

A queue converts an overload into a backlog, which is only progress if the backlog eventually drains. The number to watch is consumer lag (messages behind, or seconds behind) and its derivative: if arrival rate exceeds processing rate at all, lag grows without bound and you have an outage on a delay timer.

  • Do the arithmetic out loud. 5,000 msg/s arriving, 40 ms per message per worker → 25 msg/s per worker → 200 workers to break even, and you want ~1.5x headroom to drain a backlog rather than merely hold it. That is the sizing answer an interviewer wants.
  • Autoscale on lag, not CPU. Consumer CPU is often near-idle while blocked on a downstream call; lag is the signal that actually tracks user harm. In Kafka, remember the partition ceiling — scaling to 300 pods against 50 partitions leaves 250 idle.
  • Push backpressure upstream when the queue is unbounded in practice. Reject or rate-limit at the producer, or shed low-priority work. A 10-hour backlog of stale notifications is worse than dropping them: you'll deliver yesterday's alerts tomorrow.
  • Separate queues by priority and by latency budget. One shared queue means a 4-hour bulk-import backlog delays password-reset emails. Different SLOs deserve different queues — this is the cheapest reliability decision in the whole design.

Ordering: per-key is almost always enough

Global total ordering costs you all parallelism — it means one partition, one consumer, one thread. Almost no product needs it. What products actually need is per-entity ordering: user 42's profile updates must apply in order relative to each other; they have no meaningful relationship to user 91's.

So partition by the entity key. hash(userId) % partitions puts every event for a user in one partition, which has one owner in the consumer group, which processes it in offset order. You get ordering where it matters and full parallelism across keys — this is the same insight as sharding by tenant, applied to a stream.

⚠ Concurrency inside a consumer silently discards the ordering you just paid for You partitioned by userId, then the consumer hands each message to a thread pool or fires off un-awaited async calls. Ordering is gone — the broker's guarantee ends at delivery. If you need intra-consumer parallelism, shard the work by the same key inside the consumer (key-affine worker threads), never round-robin. The related trap: adding partitions later re-maps hash(key) % N, so a key moves partitions and in-flight events for that key can be reordered across the resize. Over-provision partitions up front instead.

The outbox pattern: fixing the dual-write problem

Here is a bug that survives every code review because it looks correct:

await db.orders.insert(order);        // 1. commits
await kafka.send("order.created", e); // 2. broker unreachable → throws

The order exists and nobody downstream will ever hear about it. Swapping the order just moves the failure: publish first and crash before the commit, and you've announced an order that doesn't exist. There is no ordering of two independent systems that is safe, because you cannot commit to a database and a broker atomically — that is the dual-write problem, and 2PC is not a real answer at this scale (it blocks on coordinator failure and no cloud broker offers it).

The fix is to make it a single write. Insert the event into an outbox table in the same transaction as the business row. Now either both exist or neither does. A separate relay — a poller, or Debezium tailing the write-ahead log — reads that table and publishes to the broker, marking rows sent.

order service one txn orders outbox single ACID transaction relay / CDC (Debezium) Kafka mark sent Both rows commit or neither does — the impossible state "order exists, event lost" can no longer occur.
The relay may crash mid-publish and re-send, so the outbox is at-least-once by design — which is fine, because you already made the consumer idempotent.

Cost and caveats, stated plainly: the outbox adds a write per event and a few hundred milliseconds to a few seconds of publish latency (poll interval; CDC is closer to tens of ms). You must prune sent rows or the table becomes your biggest one. And the relay gives you at-least-once, never exactly-once — the event ID in the outbox row is the consumer's de-duplication key, which is why these two patterns are always taught together. The mirror-image pattern on the consumer side is the inbox: record the processed event ID transactionally with the effect.

Recognizing it in an unseen problem

  • Signals: "send a notification", "generate a report", "process the video", "update the search index", "handle Black Friday traffic", or any single request that fans out to three or more downstream systems. If work can finish after the response, it should.
  • The naive design does everything inline and adds servers when it's slow — which scales the fast path and the slow path together, and still fails whenever the slowest third party does.
  • Queue or log? Ask "does anyone need to read these events twice, or will someone need them a year from now?" Yes → log (replay, multiple independent consumer groups, backfilling a new service). No, it's just work to be done → queue (simpler, richer routing, no partition-count planning).
  • Distinguishing it from a request/response cache: both remove latency, but a cache makes reads cheap while a queue makes writes deferrable. If the expensive thing is a read, you want the caching or CDN chapter, not this one.
  • The pitfall to name unprompted: the dual-write. If your design says "save to the DB, then publish an event", say "…via an outbox table, so I'm not doing a dual write" in the same breath. It is one clause and it reliably reads as senior.
  • The follow-up you will get: "what if the consumer processes the same message twice?" The answer is never "it won't." It's an idempotency key plus a processed-events table, written in the same transaction as the effect.
←previousCDNs↑ CovernextConsistency models→