Overview
It is 09:59:58 and a stadium show is about to go on sale on BoxOffice. At 10:00 two million fans press "Buy" for sixty thousand seats. Behind that button, the first version of BoxOffice does everything in one synchronous request: hold the seats, charge the card, send the e-mail, push a notification to the phone, update the search index and record the sale for analytics. The e-mail provider is a little slow today (800 ms instead of 80 ms), so every purchase now takes a second longer, the order service runs out of threads, and fans who were about to pay see a spinner and then an error. Nothing was broken β the system was simply wired so that every step had to finish before any user got an answer.
This class is about the other wiring: messages. Instead of calling every service and waiting, a service writes a message to a broker and moves on; whoever needs it reads it when it can. Done right, messaging absorbs spikes, decouples teams and keeps slow or failing services out of the critical path. Done carelessly, it loses orders, duplicates e-mails and hides failures in a queue nobody watches. We will learn both sides.
The story so far
Chapter 8 of the story of the course. The edge spreads the fans over stateless replicas (class 4, class 5), PostgreSQL sells each seat once (class 6) and Redis answers the event pages (class 7). The write path is still one synchronous chain, and the crowd is still 30 times too big: about 33,000 holds/s arrive for the ~1,100 one database can take. This class answers: what can leave the request and wait in a queue, how do we never lose or double an event on the way, and how do we give 2 million fans a fair line?
Learning objectives
By the end of this class you should be able to:
- Decide whether an interaction between two BoxOffice services should be a synchronous call, a queued command or a published event, and justify the choice with latency, availability and coupling arguments.
- Compare message queues, pub/sub topics and event logs, and choose the right palette component (
queue,pubsub,stream,kafka) for a requirement. - Explain how Kafka stores and serves data β topics, partitions, offsets, consumer groups, replication, retention and compaction β and size the number of partitions for a target throughput.
- Estimate backlog, consumer lag and drain time under a traffic spike, and design backpressure for a queue.
- Explain at-most-once, at-least-once and exactly-once delivery, and implement an idempotent consumer and a dead letter queue.
- Apply the transactional outbox, change data capture, sagas (choreography and orchestration), CQRS and event sourcing to keep services consistent without distributed transactions.
- Evolve an event schema without breaking consumers.
Roadmap
- This overview β synchronous vs asynchronous, what a broker buys you (and what it costs), load leveling in numbers, the vocabulary, and the four messaging components of the palette.
- Queues and pub/sub β point-to-point queues, competing consumers, acknowledgements, backpressure, the flash-sale waiting room, and pub/sub fan-out.
- Kafka and event logs β the log abstraction, partitions, keys and ordering, consumer groups, replication, retention, compaction and partition sizing.
- Delivery semantics β at-most/at-least/exactly-once, idempotent consumers, ordering, retries and dead letter queues, schema evolution.
- Patterns β transactional outbox, change data capture, sagas, CQRS and event sourcing, all applied to the BoxOffice purchase.
- Quiz β check your understanding.
Slides
Click the slides and use β β to move through them; F goes full screen and P opens the presenter view, with the notes. Open the slides in a new tab
Synchronous vs asynchronous
In a synchronous interaction the caller sends a request and waits for the answer before it continues β an HTTP or gRPC call. In an asynchronous interaction the caller hands a message to an intermediary (a broker) and continues immediately; the receiver processes the message later, possibly much later, possibly on another continent.
Compare the two versions of the BoxOffice purchase. The user clicks "Buy"; the order service must create the order, and five other services need to know about it.
sequenceDiagram
autonumber
participant U as Fan
participant O as order
participant N as notification
participant S as search
participant A as analytics
U->>+O: POST /orders
O->>+N: send e-mail + push
N-->>-O: ok (800 ms today)
O->>+S: update "seats left"
S-->>-O: ok
O->>+A: record sale
A-->>-O: ok
O-->>-U: 201 Created (after all of them) The response time is the sum of every call, and the purchase fails if any of them fails.
sequenceDiagram
autonumber
participant U as Fan
participant O as order
participant K as Kafka
participant N as notification
participant S as search
participant A as analytics
U->>+O: POST /orders
O->>K: publish OrderConfirmed
O-->>-U: 201 Created
K-->>N: OrderConfirmed
K-->>S: OrderConfirmed
K-->>A: OrderConfirmed The response time is the time to save the order and publish one message. Each consumer reads the event independently, at its own pace.
The difference is not only speed. It shows up in three numbers you already know how to compute (see Estimation):
| Property | Synchronous chain | Asynchronous events |
|---|---|---|
| Latency seen by the fan | \(t_{order} + t_{notif} + t_{search} + t_{analytics}\) | \(t_{order} + t_{publish}\) |
| Availability of "Buy" | \(A_{order} \times A_{notif} \times A_{search} \times A_{analytics}\) | \(A_{order} \times A_{broker}\) |
| Coupling | order knows every consumer and its API | order knows one topic name and the event schema |
| Consistency | everything is done when the user sees "201" | consumers catch up later (eventual consistency) |
| Failure of a consumer | the purchase fails | the consumer's backlog grows; the purchase still works |
| Debugging | one request, one stack of calls | a flow spread across topics and services: needs tracing |
BoxOffice: availability of the Buy button
Suppose order is 99.95% available and each of the three downstream services is 99.9%. Synchronously, the purchase needs all four:
That is about 2.5 hours of failed purchases per month. With events, the purchase needs order and the broker (a replicated Kafka cluster, say 99.95%):
The downstream services can still fail β but their failure now delays an e-mail instead of losing a sale.
Think first: should the seat hold be asynchronous too?
No. When the fan clicks a seat, they need to know now whether it is theirs. A seat hold is a question with an answer the caller needs to continue, and it must be strongly consistent (no double booking). It stays a synchronous call to inventory (see Transactions). Asynchrony is for work whose result the caller does not need to continue: notifications, search updates, analytics, invoices.
The rule of thumb: if the caller needs the answer to continue, call; if the caller only needs the work to happen eventually, send a message.
What a broker buys you
A message broker is a server that receives messages from producers, stores them durably and delivers them to consumers1. Putting a broker between two services gives four properties that a direct call cannot:
- Temporal decoupling. Producer and consumer do not need to be up at the same time. If
notificationis being redeployed, theOrderConfirmedevents wait in the broker. - Load leveling. The broker is a buffer: producers can write faster than consumers read for a while, and the backlog drains later. A spike becomes a queue instead of an outage.
- Fan-out. One published event can be delivered to many independent consumers. Adding a new consumer (fraud detection, a loyalty program) does not touch the producer.
- Independent scaling and failure isolation. Each consumer scales with its own load and fails on its own. A slow analytics pipeline does not slow down e-mails.
Nothing is free. The price of asynchrony:
- Eventual consistency. For a while the search page may still show "120 seats left" when there are 80. The system must be designed (and the UI worded) for that2.
- Duplicates and reordering. Most brokers deliver at least once: consumers will see the same message twice and must be idempotent (see Delivery semantics).
- Harder debugging. A purchase is now a chain of events across topics; without distributed tracing and consumer-lag metrics, failures hide in a backlog (see Observability).
- One more critical system. The broker must be replicated, monitored, upgraded and capacity-planned.
Load leveling in numbers
The flash sale is the textbook case for load leveling. Fans' purchase requests arrive much faster than inventory can process seat holds, but only for a few minutes. If order called inventory directly, the excess would turn into timeouts and errors. With a queue in between, the excess turns into backlog β messages waiting β and the only cost is waiting time.
The simulation below uses illustrative numbers: 200 requests/s before 10:00, a spike of 6,000 requests/s for three minutes, and then a crowd that thins out slowly. Workers process 2,000 or 4,000 requests/s.
Three things to read from the chart:
- The backlog grows at the difference of the rates. During the spike it grows by \(6000 - 2000 = 4000\) messages per second with the smaller worker pool: in three minutes, \(4000 \times 180 = 720{,}000\) messages.
- The wait is backlog divided by throughput β Little's Law again (see Queueing): \(720{,}000 / 2000 = 360\) s, six minutes for the last fan in line. Doubling the workers halves the peak and drains it much faster.
- The backlog only drains when arrivals fall below capacity. If the average arrival rate stays above the capacity, no queue saves you: the backlog grows forever. A queue absorbs spikes, not a permanent shortage.
Try it in the playground
The async-queue scenario puts a queue and a pool of workers behind an app server. Press Run with the spike profile and watch the queue depth and consumer lag. Then (1) lower the number of workers and see the backlog grow and the end-to-end delay rise while the API latency stays flat; (2) raise the workers until the backlog drains within a minute of the spike. Open the full playground to add an autoscaling rule on queue depth.
The vocabulary
Messaging has many words for similar things. These are the ones we use in the course.
| Term | Meaning | BoxOffice example |
|---|---|---|
| Message | Any unit of data sent through a broker: headers + payload | Any of the below |
| Command | A request for a specific receiver to do something; it may be rejected | GenerateTicketPdf { orderId } on the ticket-pdf queue |
| Event | An immutable fact about something that already happened; the producer does not know who reacts | OrderConfirmed { orderId, eventId, seats, total } |
| Producer / publisher | The service that writes messages | order |
| Consumer / subscriber | The service that reads them | notification, search, analytics |
| Broker | The server (cluster) that stores and delivers messages | Kafka, RabbitMQ, Amazon SQS |
| Queue | A channel where each message is processed by one consumer | ticket-pdf |
| Topic | A channel where each message is delivered to every subscription | order-events |
| Backlog / consumer lag | Messages written but not yet processed | "notification is 42,000 messages behind" |
| Acknowledgement (ack) | The consumer telling the broker "done, you may forget/advance" | basicAck, offset commit |
Name events in the past tense
An event is a fact: OrderConfirmed, SeatsReleased, PaymentFailed. A name in the imperative β SendConfirmationEmail β is a command in disguise: it couples the producer to one consumer and one action. Publish what happened and let each consumer decide what to do about it.
What goes inside an event
Martin Fowler distinguishes several things people mean by "event-driven"3. Two of them change how you design the payload:
The event carries little more than an identifier. Consumers that need details call back the producer.
{ "type": "OrderConfirmed", "eventId": "e-9f2c", "orderId": "o-7731", "occurredAt": "2027-03-14T10:00:07Z" }
Small and stable, but every consumer adds load on order (and depends on it being up) to fetch the details.
The event carries the data consumers need, so they never call back.
{
"type": "OrderConfirmed", "eventId": "e-9f2c", "orderId": "o-7731",
"occurredAt": "2027-03-14T10:00:07Z",
"customer": { "id": "c-12", "email": "ana@example.com", "locale": "pt-BR" },
"event": { "id": "ev-88", "name": "Stadium Show", "startsAt": "2027-05-01T21:00:00-03:00" },
"seats": ["B-12", "B-13"], "total": { "amount": 780.00, "currency": "BRL" }
}
Consumers are fully decoupled from order's availability, at the price of a bigger payload, a schema that is harder to change, and personal data (the e-mail) copied to every consumer.
The other two meanings β event sourcing and CQRS β are architectural patterns covered in Patterns.
The messaging palette
The System Design Playground has four messaging components. They are not four brands; they are four shapes of communication. The component catalog has the details of each one.
| Component | Shape | Who gets a message | After it is consumed | Ordering | Typical products |
|---|---|---|---|---|---|
queue Message Queue | point-to-point | one consumer of the pool | deleted | usually none (FIFO queues: per group) | Amazon SQS, RabbitMQ, Azure Service Bus queues, Google Cloud Tasks |
pubsub Pub/Sub | fan-out | every subscription (one copy each) | deleted per subscription | usually none (optional ordering keys) | Google Cloud Pub/Sub, Amazon SNS, Azure Service Bus topics, NATS, Redis Pub/Sub |
stream Event Stream | partitioned log (managed) | every consumer group, each at its own position | kept until retention expires | per partition / shard | Amazon Kinesis Data Streams, Azure Event Hubs, Redis Streams |
kafka Kafka | partitioned, replicated log | every consumer group, each at its own position | kept until retention expires (or compacted) | per partition | Apache Kafka, Confluent Cloud, Amazon MSK, Redpanda |
The deciding question is what happens to a message after it is read:
- In a queue, reading is taking: the message is gone once acknowledged. Great for distributing work.
- In a log, reading is looking: the message stays, and each reader keeps a bookmark. Great for many independent readers, replay and history.
flowchart TD
q1{"Does the caller need<br/>the answer to continue?"}
q1 -->|yes| sync["Synchronous call<br/>(REST / gRPC) + timeout"]
q1 -->|no| q2{"How many services<br/>must react?"}
q2 -->|"exactly one<br/>(a job to do)"| queue["queue<br/>competing consumers"]
q2 -->|"many, each its own copy"| q3{"Need replay, ordering<br/>per key, or high throughput?"}
q3 -->|no| pubsub["pubsub<br/>topic + subscriptions"]
q3 -->|yes| log["stream / kafka<br/>partitioned log"] When not to go asynchronous
| BoxOffice interaction | Best fit | Why |
|---|---|---|
| Log in, get a token | Synchronous | The user needs the token now |
| Hold seats B-12 and B-13 | Synchronous | The user needs to know now; must be strongly consistent |
| Authorize the card | Synchronous with idempotency key, or async with a status page | The user waits for the result, but the provider may be slow (see Integrations) |
| Enter the on-sale waiting room | Queue | Admit fans at the rate inventory can handle |
| Send e-mail and push after the purchase | Event (pubsub or kafka) | The user does not wait; can lag a few seconds |
| Update "seats left" in search | Event / change data capture | Eventual consistency is acceptable for browsing |
| Generate the PDF ticket | Queue (command) | One worker should do it once; retries are fine |
| Feed the data warehouse | Event stream | High volume, replayable, many readers |
The messaging concept map
Every message crosses the same path: a producer hands it to one of the four messaging components, and a consumer processes it later. The map links each palette component (green) to the concepts of this class (blue) and to the classes that continue them:
flowchart LR
order["app (order)<br/>producer"]:::comp --> queue["queue"]:::comp
order --> pubsub["pubsub"]:::comp
order --> log["stream Β· kafka"]:::comp
queue --> worker["worker"]:::comp
pubsub --> cons["notifications Β· search Β·<br/>analytics"]:::comp
log --> cons
order -.- c0["sync vs async, outbox,<br/>sagas, CQRS, event sourcing"]:::tech
queue -.- c1["competing consumers, ack,<br/>backpressure, waiting room"]:::tech
pubsub -.- c2["fan-out, subscriptions,<br/>push/pull, filters"]:::tech
log -.- c3["partitions, keys, offsets,<br/>consumer groups, retention"]:::tech
worker -.- c4["at-least-once, idempotent consumer,<br/>ordering, DLQ, schemas"]:::tech
c3 -.- d1["consensus, KRaft<br/>(class 3)"]
c4 -.- d2["retries, freshness SLO<br/>(classes 9 and 10)"]
c4 -.- d3["consumer lag, tracing<br/>(class 9)"]
classDef comp fill:#1e9e5a22,stroke:#1e9e5a
classDef tech fill:#2f6fed22,stroke:#2f6fed | Palette component | Question it answers | Concepts | Taught in | Scenarios |
|---|---|---|---|---|
app | Call and wait, or send a message? | sync vs async, availability of a chain, eventual consistency, commands vs events | this overview | async-queue |
queue | Who does this job, and at what rate? | competing consumers, ack and visibility timeout, backpressure, waiting room | Queues and pub/sub, the waiting room | async-queue, flash-sale |
pubsub | How does every interested service get its own copy? | topic, subscriptions, push/pull, filters, durable vs ephemeral | Queues and pub/sub | notification-system, chat |
stream Β· kafka | Who needs history, replay and order per key? | log, partitions, keys, consumer groups, lag, replication, retention, compaction | Kafka and event logs | event-fanout |
worker Β· notifications Β· search Β· analytics | What if a message arrives twice, late, out of order or broken? | at-least-once, idempotent consumer, version-guarded updates, DLQ, schema evolution | Delivery semantics | notification-system, payment-system |
sql + kafka Β· payment | How do I change my data and publish, across services, without 2PC? | transactional outbox, CDC, sagas, CQRS, event sourcing | Patterns | payment-system, flash-sale |
In the interview
Interviewers listen for why you add a queue. "I'll put Kafka here" is a red flag on its own; "the e-mail does not need to block the purchase, so order publishes OrderConfirmed and notification consumes it β that removes the e-mail provider from the purchase's availability chain and absorbs the 10:00 spike as backlog" is a strong signal. Always follow with the consequences you will handle: duplicates (idempotent consumer), ordering (key by orderId), failures (retries + DLQ) and monitoring (consumer lag)4.
Best practices
- Decide sync or async per call by asking "does the user need this answer now?" β the seat hold and the payment authorization stay in the request; the e-mail, the ticket PDF, search and analytics become events. In BoxOffice:
POST /ordersanswers after two inserts and a commit (lab 7 β outbox). - Publish facts about your domain, not commands to a known service β
OrderPlaced, notSendEmail: search or analytics subscribe with their own consumer group andorderdoes not change. In BoxOffice:notificationreadsorder-eventsas groupnotification. - Give every message an envelope: a unique
eventIdcreated once, aneventType, the time it happened and a schema version β consumers deduplicate on the id and route on the type. In BoxOffice: the relay copieseventIdandeventTypeinto Kafka headers (outbox box). - Key each message by the entity whose order matters β all events of one order land on one partition, in order, while different orders spread over all partitions. In BoxOffice: the relay uses the order id as the Kafka key.
- Size consumers from arrival rate Γ service time, with headroom, and compute the drain time before the on-sale β 100 orders/s at 50 ms each need at least 5 threads; with one thread a 60 s burst leaves ~4,800 messages and ~240 s of delay (lab 7 β burst and drain).
Common pitfalls
- Async for everything. Turning a question that needs an answer ("is this seat free?") into a message adds latency, complexity and a polling loop, and gains nothing.
- A queue as a capacity plan. A queue absorbs spikes; it does not fix a consumer that is permanently slower than the producer. Watch the trend of the backlog.
- Nobody watches the backlog. An unmonitored queue turns an outage into a silent delay: the API is green while e-mails are three hours late. Alert on consumer lag and on the age of the oldest message.
- Assuming exactly-once. Plan for duplicates from day one.
- Dual writes. Saving to the database and then publishing to the broker loses or invents events after a crash; use the transactional outbox.
Related
- Hands-on: Hands-on 7 β Async: Kafka, transactional outbox in
order, idempotent consumer innotification, DLQ and the waiting-room queue. - Exercise: Event-driven exercise β outbox + idempotent consumer.
- Case studies: Ticketing (BoxOffice flash sale), Notification system, Payment system, Chat.
- Classes: Compute β async work (workers and schedulers), Observability (consumer lag and tracing across topics), Reliability (retries, backoff, idempotency).
What breaks next
The e-mail left the request: in lab 7, an e-mail outage no longer fails a single order, and the waiting room lets in only what inventory can serve. The price is visibility. A purchase is now a request, a database row, an outbox relay, a Kafka record and a consumer, in four processes and at four different times: when a fan says "I paid and got no e-mail", nobody can say where the order stopped. And when the payment provider slows down, the averages on the dashboards stay green while fans fail at checkout. Following one order across services, and measuring what fans actually feel, is class 9 β Observability.
-
HOHPE, G.; WOOLF, B. Enterprise Integration Patterns: Designing, Building, and Deploying Messaging Solutions. Addison-Wesley, 2003. See also enterpriseintegrationpatterns.com.Β β©
-
KLEPPMANN, M.; RICCOMINI, C. Designing Data-Intensive Applications, 2nd ed. O'Reilly. Chapter on stream processing.Β β©
-
FOWLER, M. What do you mean by "Event-Driven"?. martinfowler.com, 2017.Β β©
-
XU, A. System Design Interview β An Insider's Guide, vol. 2. ByteByteGo, 2022. Chapter "Distributed Message Queue".Β β©