Messaging and event-driven systems
Queues, logs and topics, partitions and ordering, delivery guarantees, dead letters, the outbox pattern, sagas, event sourcing and stream processing.
On this page
A synchronous call couples two services in time: if the callee is down or slow, the caller is too. A message breaks that coupling. The producer hands work to a broker and moves on; the consumer picks it up when it can. Everything hard about this style follows from that one gap in time, and this article is about the patterns that close it safely.
The map
Read this first when short on time. Every branch is a section below.
Queue, log and topic: three shapes of a broker
Every broker is one of three shapes, and the difference is who owns the read position and whether a message survives being read. Picking the wrong shape is the most expensive mistake in this whole area, because it is the hardest to undo later.
A queue holds work. One consumer wins each message, acknowledges it, and the broker deletes it. Adding workers adds throughput, and the queue itself tracks what is outstanding. A log is an append-only file that nobody consumes away: each reader keeps its own offset, so ten independent teams can read the same events, and any of them can rewind to yesterday. A topic in the pub/sub sense fans one message out to a copy per subscriber, and the copies are then usually queues.
flowchart TB
subgraph Q["Queue: consumed once"]
direction LR
QP[producer] --> QB[[queue]]
QB --> QC1[worker 1]
QB --> QC2[worker 2]
end
subgraph L["Log: retained and replayable"]
direction LR
LP[producer] --> LB[[log]]
LB --> LC1["group A at offset 402"]
LB --> LC2["group B at offset 91"]
end
subgraph T["Topic: a copy each"]
direction LR
TP[producer] --> TB[[topic]]
TB --> TC1[subscriber 1]
TB --> TC2[subscriber 2]
end
Q ~~~ L
L ~~~ T
| Broker | Shape | Ordering | Retention | Reach for it when |
|---|---|---|---|---|
| Amazon SQS | Queue | None in standard, per message group in FIFO | Until acked, 14 days maximum | Background jobs, spiky work, nothing to replay |
| RabbitMQ | Queue behind an exchange | Per queue | Until acked | Rich routing rules, per-message priority, request and reply |
| Apache Kafka | Log | Per partition | Days, or forever with compaction | Many independent consumers, replay, stream processing, high throughput |
| Google Pub/Sub, SNS | Topic | Optional per ordering key | Pub/Sub keeps up to 7 days; SNS retries but stores nothing | Fan-out to several systems that each want their own copy |
- SNS in front of SQS is the standard AWS fan-out: the topic duplicates each event into one queue per consuming team, so a slow team backs up only its own queue.
- LinkedIn built Kafka because a queue could not serve both the real-time and the nightly batch readers of the same activity stream; retention plus per-reader offsets solved it.
- Celery on Redis or RabbitMQ is a queue in the purest sense: a Python function call turned into a message, run once, then gone.
- Broker
- The middleman that stores messages between producer and consumer. It is a database with a strong opinion about reading order.
- Acknowledgement
- The consumer telling the broker it is done. Until then the broker considers the message in flight and will hand it out again.
- Offset
- A reader's position in a log. Committing an offset is how a log-based consumer records progress.
Partitions: where ordering comes from
A topic is split into partitions so that it can be written and read in parallel, and this is the single fact that shapes everything else: ordering is guaranteed inside one partition and nowhere else. A global order across a topic would mean one writer and one reader, which is the throughput of one machine.
The producer picks a partition by hashing a key. Use the entity's id as the key and every event about that entity lands in the same partition in the order it happened, which is almost always the ordering you actually need. Events about different users may interleave freely, and that is fine.
flowchart LR
P[producer] -->|"hash(user_id) mod 3"| R{{partitioner}}
R --> P0["partition 0"]
R --> P1["partition 1"]
R --> P2["partition 2"]
P0 --> C1["consumer 1"]
P1 --> C1
P2 --> C2["consumer 2"]
A consumer group divides the partitions among its members: one partition to one consumer, never two. That is what makes scaling predictable and what caps it. Six partitions support at most six working consumers; a seventh sits idle. When a member joins, leaves or stalls, the group rebalances and partitions are reassigned, which briefly pauses consumption.
Two traps. A hot key — one celebrity account, one enormous tenant — sends a disproportionate share of traffic to a single partition, and no amount of extra consumers helps; either add the entity id to the key or accept losing ordering for that key. And adding partitions changes the hash mapping, so a key that used to go to partition 2 now goes to partition 5 while its older events are still unread on 2. Over-provision partitions at the start rather than grow them later.
- Kafka uses consumer groups exactly as described; a typical high-traffic topic is created with 30 to 100 partitions to leave room for growth.
- Kinesis calls partitions shards and charges per shard, so resharding is a deliberate, visible operation rather than a config change.
- SQS FIFO gets the same effect with a message group id: ordering holds within a group, and different groups are delivered in parallel.
- Pulsar's Key_Shared mode keeps per-key ordering while letting more consumers than partitions share the load.
Delivery guarantees: what "exactly once" really means
There are only two honest delivery guarantees, and everyone chooses the second. If the consumer acknowledges before processing, a crash loses the message: at-most-once. If it acknowledges after processing, a crash before the ack means redelivery: at-least-once. There is no third option, because the ack and the work cannot be made atomic across two machines.
sequenceDiagram autonumber participant P as Producer participant B as Broker participant C as Consumer P->>B: publish order 42 B-->>P: stored B->>C: deliver order 42 C->>C: process and write to the database C--xB: ack lost, consumer dies B->>C: redeliver order 42 after the timeout Note over C: at-least-once means the same work arrives twice C->>C: key 42 seen before, skip
"Exactly once" is therefore not a property of delivery but of effect. You get it by making the processing idempotent: an idempotency key with an atomic check-and-insert, a conditional write on a version, or a dedupe table keyed by message id. The reliability article covers those techniques in detail.
Kafka's exactly-once is a real thing with a narrow scope. A transactional producer writes the output records and the consumer's offset commit in one atomic transaction, and readers set isolation.level=read_committed so they never see records from an aborted one. That makes consume-transform-produce chains safe inside Kafka. It says nothing about a side effect in Postgres or a charge at a payment provider; those still need an idempotency key.
- SQS standard is at-least-once and says so plainly; SQS FIFO deduplicates identical messages within a five-minute window, which helps only with producer retries.
- Kafka enables the idempotent producer by default since 3.0, so a retried publish no longer writes the record twice.
- Flink reaches end-to-end effectively-once with two-phase-commit sinks: the sink pre-commits on each checkpoint and commits when the checkpoint completes.
Retries, dead letters and backpressure
Some messages fail because the world was briefly broken, and some fail because they will never succeed. The design question is how to keep retrying the first kind without letting the second kind stop everything.
A message that cannot be processed is redelivered: the consumer nacks it, or its visibility timeout expires and the broker hands it back. If nothing changes, that repeats forever. In a queue this burns capacity; in a log it is worse, because the consumer cannot commit past the bad offset and the whole partition stops.
flowchart TD
M[message delivered] --> P{Processed?}
P -- yes --> A[commit and move on]
P -- no --> R{Retries left?}
R -- yes --> D["retry topic with a delay"]
D --> M
R -- no --> Z[[dead-letter queue]]
Z --> H[alert, inspect, replay after a fix]
Two mechanisms do the work. Delay tiers: instead of retrying in place, republish to a retry topic with a delay — 5 seconds, then 1 minute, then 10 — so the main topic keeps moving while the failing message waits. A dead-letter queue: after a retry cap, move the message aside with its error and let a human look. Retries should use exponential backoff with jitter, for the same reason as any other retry.
Backpressure in a broker is not a signal you receive; it is a number you watch. Consumer lag — how far behind the newest offset a group is — is the one metric that tells you whether consumption is keeping up. Rising lag means add consumers (up to the partition count), make processing cheaper, or accept the delay. The broker's retention is the real deadline: if lag grows past it, messages are deleted unread.
A dead-letter queue nobody alerts on is a silent data-loss bucket. Alert on its depth, and keep the original message plus the failure reason so it can be replayed once the bug is fixed.
- SQS has this built in: a redrive policy moves a message to the dead-letter queue after
maxReceiveCountdeliveries, and a redrive command sends the fixed batch back. - RabbitMQ builds delays out of a dead-letter exchange plus a per-message TTL, since it has no native scheduled delivery.
- Uber published the tiered retry-topic pattern for Kafka, which is now the common way to retry without blocking a partition.
- Visibility timeout
- How long a queue hides a delivered message from other consumers. If no ack arrives in that window, the message reappears.
- Consumer lag
- The gap between the last offset produced and the last offset committed by a group. The health metric for any log consumer.
The dual write problem: outbox and change data capture
A service that saves an order and then publishes OrderPlaced is writing to two systems that cannot commit together. Crash after the database commit and the event never happens; publish first and fail to commit and you have announced an order that does not exist. This is the dual write problem, and it is the most common correctness bug in event-driven systems.
The transactional outbox solves it by making the event part of the database transaction. The service inserts the order row and an outbox row atomically; a separate relay reads unsent outbox rows, publishes them, and marks them sent. The event is now as durable as the order.
CREATE TABLE outbox (
id BIGSERIAL PRIMARY KEY,
aggregate TEXT NOT NULL, -- 'order'
event_type TEXT NOT NULL, -- 'OrderPlaced'
payload JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
sent_at TIMESTAMPTZ
);sequenceDiagram autonumber participant S as Order service participant D as Database participant R as Relay participant K as Broker S->>D: begin transaction S->>D: insert the order row S->>D: insert the outbox row S->>D: commit Note over D: both rows commit, or neither does R->>D: poll for unsent outbox rows D-->>R: event 8814 R->>K: publish event 8814 R->>D: mark 8814 as sent
Change data capture is the same idea without the outbox table: a connector tails the database's own replication log — Postgres's write-ahead log, MySQL's binlog — and turns every committed row change into an event. Nothing in the application changes, ordering matches commit order exactly, and no polling is needed. The cost is that the events are row shapes rather than domain facts, so a table refactor leaks into consumers. Many teams use CDC on the outbox table and get both.
- Debezium is the standard CDC connector for Postgres, MySQL, MongoDB and SQL Server, and streams into Kafka through Kafka Connect.
- DynamoDB Streams and MongoDB change streams are CDC built into the database, commonly wired straight to a Lambda or a worker.
- Airbnb, Shopify and Netflix all run CDC pipelines to keep search indexes and caches in step with the source database instead of dual writing.
Sagas: a transaction across services
An order touches payment, inventory and shipping, each with its own database. A single ACID transaction across them would need two-phase commit, which holds locks across services and blocks everything if the coordinator dies. A saga replaces it with a sequence of local transactions, each paired with a compensating action that undoes it.
Compensation is semantic, not a rollback. You cannot un-charge a card; you issue a refund, which is a new, visible fact. That is the trade: the saga never blocks, but intermediate states are visible to everyone, so there is a moment where the customer has been charged for an order that is about to fail.
sequenceDiagram autonumber participant O as Orchestrator participant P as Payment participant I as Inventory participant H as Shipping O->>P: charge the card P-->>O: charged O->>I: reserve stock I-->>O: reserved O->>H: book a courier H-->>O: none available Note over O,H: step 3 failed, so undo steps 2 and 1 in reverse O->>I: release the stock O->>P: refund the charge
| Approach | How it coordinates | Strengths | Costs |
|---|---|---|---|
| Choreography | Each service listens for events and emits the next one | No extra component, services stay decoupled | The flow exists nowhere; debugging means reading every service |
| Orchestration | One coordinator calls each step and tracks the state | The whole flow is in one place, visible, testable, retriable | One more service to run, and it must not become a monolith |
| Two-phase commit | A coordinator prepares all participants, then commits | True atomicity and isolation | Holds locks, blocks on coordinator failure, rarely available across services |
Choose choreography for two or three steps, orchestration beyond that. The moment somebody asks "where is order 7741 stuck", choreography has no answer and orchestration does.
- Temporal and its ancestor Uber Cadence run the saga as ordinary code whose state is durable across crashes, which is why they have largely replaced hand-rolled coordinators.
- AWS Step Functions expresses the same thing as a state machine, with retries and catch branches per step.
- Netflix Conductor orchestrates the long-running media pipelines behind an encode, where a step can take hours.
Event sourcing and CQRS: storing facts, not state
Event sourcing stores what happened instead of what is. A row that says balance = 40 has forgotten how it got there; a stream of Deposited 100, Withdrew 60 has not. Current state is a fold over the events, and a snapshot every few hundred events keeps that fold cheap.
What you gain is the audit trail as the source of truth, the ability to answer questions nobody had thought of when the write was made, and a way to rebuild any derived data by replaying. What you pay is real: events are immutable, so a schema change means upcasting old ones forever, and a full replay of a large stream is measured in hours.
CQRS is the natural partner: separate the write model from the read models. Writes validate and append; each read model is a projection built by consuming the log and shaped for exactly one query. The order list, the search index and the monthly totals stop competing for one schema.
flowchart TD C[commands] --> W["write model, validates and appends"] W --> E[["event log, the source of truth"]] E --> P1["projection: order list"] E --> P2["projection: search index"] E --> P3["projection: monthly totals"] P1 --> Q["read queries, eventually consistent"] P2 --> Q P3 --> Q
Projections lag the write by milliseconds to seconds, so a user who submits a form and is redirected to a list may not see their own change. Handle it in the interface — return the new entity from the write, or wait for the projection's version — rather than pretending the lag is not there. And do not event-source a CRUD application: if nobody will ever ask how the value got there, a table is the right answer.
- Double-entry ledgers are event sourcing from before computers: you never edit a posting, you add a correcting one.
- Kafka Streams and ksqlDB keep projections in local state stores backed by a compacted Kafka topic, so a lost node rebuilds by replaying.
- EventStoreDB and Axon are purpose-built stores for this model, with streams per aggregate and built-in projections.
- Projection
- A read model built by consuming events. Disposable by design: delete it and replay to rebuild.
- Upcasting
- Translating an old event version into the current shape when it is read, because the stored event can never be edited.
- Log compaction
- Keeping only the newest record per key in a log, so it can be retained forever as a snapshot of current state.
Stream processing: continuous queries over a log
Stream processing is running a query that never ends. Stateless steps — map, filter, route — are easy and need nothing from the framework. The value is in the stateful ones: counting per key, joining two streams, detecting a pattern over a few minutes. Those need somewhere to keep state and a way to recover it after a crash.
The hard part is time. Event time is when the thing happened; processing time is when the record arrived. A phone offline in a tunnel delivers its events ten minutes late, so a count by event time cannot simply close at the wall clock. A watermark is the framework's declaration that it believes no event older than time T will arrive; when the watermark passes a window's end, the window closes and emits. Anything later is dropped, or handled by an allowed lateness that re-emits an updated result.
| Window | Shape | An event belongs to | Typical use |
|---|---|---|---|
| Tumbling | Fixed size, no overlap: 00:00–00:05, 00:05–00:10 | Exactly one window | Per-minute counts, billing buckets |
| Hopping or sliding | Fixed size, advanced by a smaller step: 5 minutes every 1 | Several windows | Rolling averages, alert thresholds |
| Session | Grows while events keep coming, closes after a gap | One session per key | User visits, device activity bursts |
State lives in an embedded store — usually RocksDB on local disk — and is made durable by periodic checkpoints to object storage. On failure the job restarts from the last checkpoint and rewinds its source offsets to match, which is how a stream job recovers without double-counting.
- Apache Flink is the reference implementation of event time and watermarks, and runs the real-time pipelines at Netflix, Uber and Alibaba.
- Kafka Streams is a library rather than a cluster: the job is an ordinary application, and scaling it means starting more copies in the same consumer group.
- Spark Structured Streaming processes micro-batches, which trades a second or so of latency for reuse of the whole Spark ecosystem.
- Materialize and RisingWave expose the same machinery as incrementally maintained SQL views.
Recap
- Queues destroy a message when it is consumed, logs retain it and let each reader keep its own offset, topics fan out a copy per subscriber. Pick the shape first; it is the hardest decision to reverse.
- Ordering exists only inside a partition. Key by entity id to get per-entity order, and accept that different entities interleave.
- One partition goes to one consumer in a group, so the partition count is the hard ceiling on parallelism. Over-provision it: adding partitions later remaps keys.
- Delivery is at-most-once or at-least-once. Everyone picks at-least-once, so every consumer needs an idempotency key, a conditional write or a dedupe table.
- Kafka's exactly-once is a transaction over records and offsets inside Kafka. It does not extend to a database write or a third-party call.
- Bound retries with delay tiers so a failing message does not block a partition, and end them in a dead-letter queue that is alerted on.
- Consumer lag is the backpressure signal. Retention is the deadline: lag beyond it means messages are deleted unread.
- Never write to the database and publish separately. Use a transactional outbox, or change data capture off the write-ahead log, so the event is as durable as the row.
- Sagas replace distributed transactions with local transactions plus compensations. Choreography for two or three steps, orchestration once you need to know where an order is stuck.
- Event sourcing stores facts and derives state; CQRS gives each query its own projection, rebuildable by replay. Both cost schema evolution and eventual consistency, so do not apply them to CRUD.
- Stream processing turns on event time: watermarks decide when a window closes, tumbling, hopping and session windows cover the shapes, and checkpoints make state recoverable.
Questions
Try answering each one out loud before opening it. Lead with the one-line answer, then the reasoning, then one detail from a real system.
What is the difference between a message queue and a commit log?
A queue deletes a message once a consumer acknowledges it and the broker tracks what is outstanding; a log keeps the message for a retention period and each reader tracks its own position, so many independent consumers can read the same records and any of them can rewind.
- The queue is for work that must be done once; the log is for facts that several systems care about.
- Replay is free in a log and impossible in a queue.
- A queue's depth naturally drains to zero; a log's size is set by retention, not by consumption.
SQS and Kafka look interchangeable in a diagram, but a team that later needs to rebuild a search index from history will only manage it on the log.
How do you guarantee that events for one user are processed in order?
Use the user id as the partition key, so every event for that user hashes to the same partition, and rely on the fact that a partition is read in order by exactly one consumer in the group.
- Global ordering across a topic would require one partition and therefore one consumer, which does not scale.
- Different users may interleave freely, which is almost always acceptable.
- Inside the consumer, processing must stay single-threaded per partition or the ordering is lost again.
SQS FIFO expresses the same idea as a message group id, which is a partition key by another name.
Why is adding partitions to a busy topic risky?
Because the partition is chosen by hashing the key modulo the partition count, increasing that count sends existing keys to new partitions while their older events are still unconsumed on the old ones, so per-key ordering breaks for the duration.
- There is no rebalancing of existing records: old data stays where it was written.
- Consumers may briefly see a later event before an earlier one for the same key.
- The safe approaches are over-provisioning partitions up front, or draining the topic before resizing.
It is the same failure mode that consistent hashing exists to avoid, which is why Kafka's partitioner is a deliberate trade for simplicity.
Is exactly-once delivery possible? What do systems that claim it actually do?
Exactly-once delivery is not achievable, because acknowledging and processing cannot be made atomic across a network; what systems provide is exactly-once effect, built from at-least-once delivery plus idempotent or transactional processing.
- Ack before processing loses messages, ack after processing duplicates them, and there is no third ordering.
- Kafka transactions atomically commit output records and consumer offsets, so a consume-transform-produce chain is effectively-once inside Kafka.
- Any external side effect, such as a payment, still needs an idempotency key.
SQS FIFO deduplicates only within a five-minute window, which protects against producer retries and nothing else.
A single malformed message keeps failing. What happens, and how should the system handle it?
It is redelivered forever, and in a log-based consumer it blocks the whole partition because the offset cannot advance past it; the fix is a bounded retry with delays, then a dead-letter queue.
- Retry in a separate delay topic so the main topic keeps moving.
- Cap attempts, then move the message aside with its error for a human.
- Alert on dead-letter depth, or it becomes silent data loss.
SQS implements this natively with a redrive policy and maxReceiveCount, and can replay the fixed batch back to the source queue.
Your consumer lag is growing steadily. Walk through the diagnosis.
Lag growing means consumption is slower than production, so the question is whether to add consumers, make each message cheaper, or accept the delay before retention deletes unread data.
- Check whether all partitions lag or only some: uneven lag points at a hot key or a stuck consumer, not at capacity.
- Adding consumers helps only up to the partition count; past that the extras are idle.
- Look at per-message cost first, since a slow downstream call is usually the real limit.
The deadline is retention: once lag exceeds it the broker deletes records the group has not read, and that loss is unrecoverable.
What is the dual write problem, and how do you solve it?
It is writing to the database and publishing to the broker as two separate operations, which cannot both be made to happen: a crash between them either loses the event or announces something that was never committed. The fix is to make the event part of the database transaction, through a transactional outbox or change data capture.
- The outbox writes an event row in the same transaction; a relay publishes it and marks it sent.
- CDC tails the write-ahead log or binlog instead, so ordering matches commit order and the application does not change.
- Both are at-least-once, so consumers must still deduplicate.
Debezium on Postgres logical replication is the common implementation, and many teams point it at the outbox table to get domain-shaped events with CDC's ordering.
Compare a saga with two-phase commit.
Two-phase commit gives real atomicity by holding locks across participants until a coordinator says commit, which blocks everything if the coordinator dies; a saga gives up isolation instead, running local transactions with compensating actions so nothing ever blocks.
- A saga's intermediate states are visible, so a customer can be charged for an order that later fails.
- Compensation is semantic: a refund, not a rollback, and some actions cannot be undone at all.
- 2PC is practical inside one database or one resource manager, rarely across services.
Choosing a saga is choosing availability over isolation, which is the same trade the CAP discussion describes at the data layer.
Choreography or orchestration for a five-step checkout flow?
Orchestration, because at five steps the flow no longer fits in anyone's head and someone will need to answer where a given order is stuck.
- Choreography spreads the sequence across services, so the whole flow exists only as a diagram in a document.
- An orchestrator makes the state explicit, testable, and retriable from the failed step.
- The risk is the coordinator absorbing business logic that belongs in the services.
Temporal makes this practical by running the coordinator as ordinary code whose execution state survives process restarts.
When is event sourcing the wrong choice?
When nobody will ever ask how a value got to be what it is: a CRUD application pays all of event sourcing's costs and collects none of its benefits.
- Events are immutable, so every schema change means upcasting old versions forever.
- Rebuilding a projection from a large stream can take hours, which has to be planned for.
- Every read becomes eventually consistent, which the interface must handle.
Ledgers, order flows and anything audited are where the model pays, because the audit trail is the product rather than a side effect.
Explain event time, processing time and watermarks.
Event time is when something happened, processing time is when the record reached the job, and a watermark is the job's declaration that it expects no further events older than a given time, which is what lets a window close.
- Records arrive out of order, so closing a window on the wall clock would undercount.
- Allowed lateness keeps a window's state alive briefly and re-emits an updated result.
- A watermark is a heuristic: set it too tight and you drop data, too loose and results are late.
Flink's watermark generators are configurable per source precisely because the right lateness depends on whether the producer is a server or a phone.
Design the event flow for an order that must update inventory, notify the user and feed analytics.
The order service writes the order and an outbox row in one transaction, a relay or CDC connector publishes OrderPlaced to a log partitioned by order id, and inventory, notifications and analytics each consume it as an independent group with its own offsets.
- A log rather than a queue, because three unrelated consumers want the same event and analytics will want to replay it.
- Each consumer deduplicates on the event id, since delivery is at-least-once.
- Inventory failures go to a retry topic and then a dead-letter queue, and must not block notifications.
If inventory must be reserved before the order is confirmed, that is no longer a fan-out but a saga, with a release-stock compensation.