Step 5 of 10 · 35 min

Async Processing and Messaging

Slow or unreliable work (email, video, payments) should not hold the user's request. Queues absorb spikes and let work retry safely.

✓ Decide what to make async✓ Pick between a queue, pub/sub and a log✓ Build idempotent consumers with retries and a DLQ✓ Use the outbox to avoid dual writes
Say it in 30 seconds
  • 720Kbacklog after an hour at 1,000 in, 800 out
  • 1, 2, 4, 8 sexponential backoff for transient failures
  • 200 slotsworkers for 1,000 jobs/s at 200 ms each
Show what to say

“I move slow, unreliable or spiky work off the request path onto a queue: the API records the intent and answers fast, and a worker retries until it succeeds. Delivery is at-least-once, so consumers are idempotent with a unique constraint on the message id, transient failures retry with backoff, and poison messages go to a dead letter queue.”

Trade-offs

  • Sync vs async: async keeps the request fast and absorbs spikes, but work happens later, maybe twice, maybe out of order.
  • At-most-once vs at-least-once: acking first can lose a message in a crash; acking after processing can redeliver it, so dedupe.
  • Outbox vs write then publish: the outbox adds a relay and may publish a row twice, but a crash never loses the event.

Bring it up before they ask

A DLQ nobody alerts on is a silent loss, so alert on depth above zero and on the age of the oldest message.

New to the words? Read the one-minute primer

0 of 0 sections ticked
30-second answer

Move slow, flaky or spiky work onto a queue. Delivery is , so make , retry with , and use a instead of "write the database, then publish".

Why it matters

Send the welcome email inline and signup is as slow as the email provider (say 800 ms).

Read more · 284 words

Queue it instead: the API answers in tens of milliseconds and a worker sends the email, retrying if it fails. If the provider is down for an hour, inline signups fail; queued ones just wait.

The price: work happens later, maybe twice, maybe out of order. Skip the queue for fast work the user needs in the response: it adds a to run. Play Burst absorbed.

If the user needs the answer to carry on ("was my card accepted?"), keep it sync. Go async when work is slow, unreliable, spiky or fans out to many reactions. Walk the tree with your own example.

RequestSync part (user waits)Async part (worker)
Sign upCreate the user rowWelcome email, CRM sync
Upload a videoStore the file, return an idTranscode renditions, thumbnails
Place an orderReserve stock, authorise the cardConfirmation email, invoice PDF, analytics
Publish a postSave the postFan out to followers' feeds

Most flows split in two: the API saves what it must, publishes a job and returns 202 Accepted with a job id. Use it when the work takes more than about a second, or depends on something you do not control (an email provider, a card network, a transcoder). The client either polls GET /jobs/8841 every couple of seconds or gets a push when the job ends. The cost is a status endpoint to build, and a failure that arrives after the user has left, so you also need a way to tell them (an email, a notification, a status badge).

Press Play to watch it run · 7 steps

The API publishes jobs to a queue and returns at once. Two workers consume them and write to the database; messages that keep failing are parked in a dead letter queue.

Queue with workers and a dead letter queueThe API publishes jobs to a queue and returns at once. Two workers consume them and write to the database; messages that keep failing are parked in a dead letter queue.publishconsumeconsumeafter retriesAPIreturns 202Queue / KafkaWorker 1Worker 2Messages that failed every retry wait here for a human or a redrive.Dead letter queueDatabase

Tap any box to see what it is and what it talks to.

  • Service
  • Queue
  • Worker
  • Database
Open in playground
Text description
  1. A user signs up. The API publishes a send-welcome-email job and answers 202 Accepted straight away.
  2. Worker 1 pulls the message. The broker hides it from other workers while it is in flight.
  3. Worker 1 does the slow part (calls the email provider) and records the result.
  4. Worker 1 acks the message only after the work succeeded, so the broker deletes it.
  5. More signups arrive while that was happening.
  6. The two workers take messages in parallel. Adding workers adds throughput.
  7. Both jobs finish and are acked. The user never waited for any of this.
Sync or async?yesyesnonoKeep it syncGo asyncInline is fineIs the work slow, flaky,spiky or a fan-out tomany reactions?Does the user need theresult to carry on?

Does the user need the result to carry on?

Queues, pub/sub and Kafka

With a queue in between, the can be slow, mid-deploy or briefly down and nothing is lost.

Read more · 580 words

That is decoupling: each side scales and fails on its own. "Queue" covers three tools, and the difference is who gets each message. First, put the missing box back.

Which box lets signup succeed while the email provider is down?

ToolHow it worksUse it forWatch out for
Work queue (default)Each message goes to exactly one consumer, so more workers means more throughput. The broker hides a taken message for a visibility timeout (30 s by default in SQS) and deletes it only on ack; no ack means it reappearsJobs run once: emails, thumbnails, webhooks, invoice PDFs. SQS on AWS with no servers; RabbitMQ for routing rules or prioritiesOnce acked a message is gone, so no replay. A standard SQS queue only promises best-effort order
Pub/subEvery message goes to every subscriber. The publisher does not know who listens, so adding a search indexer touches no existing codeOne event, several services care: order placed, then email, analytics and searchPlain pub/sub is fire-and-forget: a subscriber that is down misses the event. Fix: a queue behind each subscriber (SNS topic to one SQS queue per consumer)
Log (Kafka)Appends messages and keeps them. Reading does not delete; each consumer group tracks its own position, so any group can rewindEvent streams, analytics, change feeds, a second consumer next month that needs historyYou run a cluster (or pay for a managed one); order holds only inside a partition; consumers manage their own offsets

Decide on one question first: does anyone besides the consumer need these messages, or need to replay them? If yes, a log. If no, a work queue. The default interview answer: "a queue for jobs, for event streams".

Kafka is a log, not a mailbox. A topic splits into partitions. A key always lands in the same partition, and order holds only within it. In a consumer group each partition has one consumer; other groups read the same topic independently.

  • Partitions are the unit of order and parallelism. The producer hashes the key to pick one. With 6 partitions and 8 consumers, 2 sit idle. Add consumers past the partition count in the simulator.
  • Choose the partition count for the consumers you expect at peak, with headroom. You can add partitions later but never remove them, and adding them changes which partition a key maps to, which breaks per-key order for in-flight data.
  • Consumer groups: use one group per application (billing is a group, analytics another, and each sees every message). Scale a group up to the partition count, not past it. When a consumer joins, leaves or crashes, Kafka reassigns partitions (a rebalance) and reads pause briefly.
  • Replay and retention: the log keeps messages for a set time (7 days by default), read or not. Consumers commit their position (an offset), so a group can rewind after a bug fix and rebuild its state.
  • Retention is a disk bill. A topic taking 5 MB/s writes 432 GB a day (5 MB x 86,400 s). Seven days is about 3 TB, and with 3 about 9 TB. Shorten retention, compact by key, or compress before you buy disks.
Interactive

Fill in the missing component

One box in this diagram is empty. Drag the component that belongs there, or press its Place button.

Signup with a welcome emailThe signup API saves the user, hands the email to something, and answers. A worker talks to the email provider.INSERTpublishBrowserSignup APIUsers DBMissing component: drag one here?Email workerEmail providerSES, SendGrid

Tap any box to see what it is and what it talks to.

The signup API saves the user, hands the email to something, and answers. A worker talks to the email provider.

  • Client
  • Service
  • Database
  • Worker
  • External API
Open in playground

Pick the component that belongs in the empty box.

Which messaging tool?yesyesnonoKafka (a log)RabbitMQSQS or a similar workqueueNeed routing rules orpriorities beyond aplain queue?Do several services needthe same events, or needto replay them?

Do several services need the same events, or need to replay them?

Interactive

Kafka partitions and consumers

A consumer group splits partitions between its consumers; extra consumers sit idle. A key always maps to the same partition, which keeps that key's messages in order.

partitions (newest →)consumer groupProducerhash(key) % PP0P1P2P3C1P0–P1C2P2C3P3
  • user-1 (u1)
  • user-2 (u2)
  • user-3 (u3)
  • user-4 (u4)

4 partitions, 3 consumers: C1 reads P0–P1, C2 reads P2, C3 reads P3.

Press Play to watch it run · 3 steps

Kafka appends messages to a log and keeps them. Each consumer group tracks its own position, so groups read independently and can rewind.

A log read by two consumer groupsKafka appends messages to a log and keeps them. Each consumer group tracks its own position, so groups read independently and can rewind.writereadreadProducerKafka topic7 days keptBilling groupAnalytics group

Tap any box to see what it is and what it talks to.

  • Service
  • Queue
  • Worker
Open in playground
Text description
  1. The producer appends an event. Reading never removes it.
  2. The billing group reads it and commits its position (offset).
  3. The analytics group reads the same event at its own pace. A work queue would have given it to one of them only.

Delivery and ordering

Your will see the same message twice.

Read more · 801 words

redeliver after crashes: that is , the default. "Exactly once" really means at-least-once plus work: doing it twice has the effect of doing it once. When you decides what a crash costs.

GuaranteeHowUse it forWatch out for
At-most-onceAck first, then process. A crash loses the messageData where one lost item costs nothing: a metrics ping, a debug log line, a position update replaced in a secondNever for emails, orders or money: a loss is silent
At-least-once (default in SQS, RabbitMQ, Kafka)Process, then ack. A crash before the ack means redelivery: the work can run twice, never zero timesAlmost everythingDuplicates. Scene: at 9:03 a worker charges a card, its pod is evicted before the ack, and a second worker charges again after the visibility timeout. You cannot prevent redelivery, only make it harmless
Effectively onceAt-least-once plus an idempotent consumerAnything with side effectsNeeds a dedupe table or idempotent writes

Two ways to make work idempotent. A table: record the message id under a unique constraint, in the same as the effect. A redelivery inserts zero rows and is skipped.

Idempotent writes: SET status = 'shipped' is safe twice; balance = balance + 10 isn't. For a call to another system, send an (payment providers accept one) so the same key never charges twice. The dedupe table needs a clean-up job: keep ids for longer than the broker can redeliver, for example 7 days.

"Exactly once" is real only in narrow cases. 's works for read, process, write that stays inside Kafka. SQS FIFO drops a duplicate send that has the same dedupe id within 5 minutes. Neither stops your worker sending an email twice, so you still need an idempotent consumer.

Ordering. "Order shipped" before "order paid" is a bug, and Kafka orders only within a partition. So ask which messages must be ordered against each other: almost never "all of them".

  • Key by the entity that needs order (userId, orderId): one key, one partition, one consumer, so events apply in send order, while other users run in parallel. Use it for anything with a lifecycle (created, paid, shipped). The cost is a if one key is huge: a celebrity user with 40% of the traffic pins one consumer while the others idle. Split the key (user:7#0 to #3) only if order inside that user does not matter.
  • Retries reorder. A failed message parked on a retry topic for 30 s lets the next one run first: the order ships before it is paid. Where order matters, retry in place and let the messages behind it wait. The price: one stuck message blocks its partition, so cap the attempts (say 5), then move it to the and carry on. Enable the idempotent producer (idempotent: true in the snippet) so a producer retry cannot duplicate or reorder a send.
  • Sequence numbers. A per-key seq lets the consumer drop duplicates and spot gaps, without relying on the broker alone. Keep the last applied seq per key.

Global order is rarely needed. It needs one partition and one consumer, which caps at what a single consumer can do. Ask what the business really needs: one user's events in order, not two strangers'.

Idempotency is a PAID stamp on a receipt: stamping it twice still leaves it paid once.

Press Play to watch it run · 4 steps

Ack before the work and a crash loses the message. Ack after the work and a crash delivers it again.

When you ack decides what a crash costsAck before the work and a crash loses the message. Ack after the work and a crash delivers it again.takesendQueueWorkerEmail provider

Tap any box to see what it is and what it talks to.

  • Queue
  • Worker
  • External API
Open in playground
Text description
  1. The worker receives a send-receipt job.
  2. It acks immediately, so the broker deletes the message.
  3. The worker crashes before it calls the provider.
  4. Nothing is left to redeliver. The receipt is never sent and nobody knows.
BEGIN;
INSERT INTO processed_messages (message_id) VALUES ($1)
  ON CONFLICT (message_id) DO NOTHING;
-- 0 rows inserted means a duplicate: ROLLBACK and ack the message
UPDATE accounts SET balance = balance + $2 WHERE id = $3;
COMMIT;
if (event.seq <= last) return;               // duplicate or stale: drop it
if (event.seq > last + 1) return parkUntil(last + 1); // gap: wait for the missing one
apply(event); last = event.seq;

Retries, dead letters and delayed jobs

Some failures heal; some never will.

Read more · 594 words

Retry the first, waiting longer each time (). Park the second in a (DLQ). The triage is one question: could this exact message ever succeed?

FailureWhat happensFix
Transient (timeouts, 503s, deadlocks)Usually heals if you wait. Without jitter, 1,000 consumers that failed together retry together at 1 s, 2 s and 4 s, and the recovering service sees synchronized wavesExponential backoff plus jitter: 1 s, 2 s, 4 s, 8 s is 15 s of waiting over four retries. Cap attempts at about 5 and the delay at tens of seconds
Poison message (bad payload or a bug)Always fails; without a DLQ it retries forever and burns worker timePermanent errors go straight to the DLQ; the rest after the last attempt. In SQS set maxReceiveCount on the redrive policy (for example 5). Alert when the DLQ is not empty. Fix the bug, then redrive. Kafka has no built-in DLQ: your consumer publishes the bad record to a dead letter topic
Visibility timeout too shortA job takes 60 s, the timeout is 30 s. At second 30 the broker gives the job to another worker, so it runs twice at onceSet the timeout above the slowest job (p99 plus margin), or extend it while the worker is still busy
One bad message blocks a partitionKafka commits offsets in order, so a message that fails forever at offset 100 stops offsets 101 and up, and lag grows. It looks like a healthy consumer that processes nothingAfter the attempt limit, publish it to a dead letter topic and commit past it. Alert on consumer lag too

Use backoff by default for any call to another service. Try the simulator and compare no retries, fixed, exponential and jittered.

Delayed and scheduled jobs. To cancel unpaid orders after 15 minutes, use a job that is invisible until its time:

  • Short delays: the . SQS DelaySeconds (up to 15 minutes) or BullMQ's delay: one line, no extra table. Use it under 15 minutes if you can tolerate the job living in the broker. A Redis-backed delay survives a restart only with persistence on.
  • Long delays: a run_at table and a poller. For hours or days, store run_at and poll due rows with FOR UPDATE SKIP LOCKED, or use a scored by run time. The job re-reads the state when it fires, so a paid order is left alone. It is durable, queryable and cancelable; precision is one poll interval (about a second); the cost is a poller and a partial index on pending rows.
  • Cron-style schedules sometimes run twice, because two scheduler instances can fire at once after a deploy. Key the job on the period (report:2026-10-24) and record that key under a unique constraint.
Backoff is a staircase where each retry waits about twice as long as the last, so the server gets room to recover.
What to do with a failed messageyesnoyesnoBack off and slow downRetry with backoffIs the downstreamoverloaded (429 or 503)?Dead letter queue nowCould this exact messageever succeed on a retry?

Could this exact message ever succeed on a retry?

const delay = Math.random() * Math.min(capMs, baseMs * 2 ** attempt); // full jitter
Interactive

Retry storm

300 clients fail at once; the server is back at 10 s and answers 20 requests per 100 ms.

Retry policy
Peak after recovery
165 / 100 ms
full jitter: 12
Everyone served by
not by 30 s
280 failing · full jitter: 17.5 s
Requests sent
1,790
6.0 per client
Requests arriving at the server per 100 ms (line: recovery at 10 s)
  • Exponential
  • Full jitter
050100150200req07.51522.429.9Time (s)ExponentialFull jitter
Show data table
Requests arriving at the server per 100 ms (line: recovery at 10 s)
Time (s)Exponential (req)Full jitter (req)
0135143
0.1165184
0.2028
0.3029
0.4037
0.5033
0.6034
0.7048
0.8049
0.9044
113533
1.116528
1.2012
1.3017
1.4015
1.5018
1.6018
1.7018
1.8024
1.9015
2030
2.1025
2.2023
2.3019
2.4016
2.5017
2.6017
2.709
2.8013
2.9012
313512
3.11658
3.2022
3.303
3.4017
3.5010
3.604
3.709
3.8011
3.9012
406
4.1015
4.2010
4.3012
4.409
4.509
4.609
4.7015
4.8011
4.907
5014
5.109
5.2010
5.3011
5.406
5.505
5.6010
5.706
5.805
5.907
608
6.108
6.2012
6.308
6.404
6.505
6.604
6.708
6.806
6.904
71355
7.11652
7.2011
7.306
7.4011
7.504
7.609
7.706
7.808
7.9010
8013
8.105
8.207
8.3011
8.404
8.5010
8.6012
8.707
8.808
8.908
9010
9.109
9.203
9.304
9.407
9.505
9.609
9.707
9.8011
9.909
1004
10.106
10.207
10.3011
10.4012
10.509
10.607
10.708
10.806
10.906
11011
11.109
11.207
11.309
11.405
11.505
11.608
11.707
11.805
11.903
1208
12.105
12.203
12.305
12.406
12.505
12.606
12.705
12.803
12.907
1304
13.105
13.201
13.303
13.402
13.504
13.605
13.701
13.806
13.902
1402
14.103
14.202
14.302
14.404
14.503
14.604
14.703
14.802
14.903
151352
15.11658
15.203
15.300
15.404
15.503
15.603
15.700
15.800
15.901
1602
16.100
16.203
16.300
16.401
16.503
16.600
16.701
16.803
16.901
1702
17.100
17.200
17.300
17.400
17.501
17.600
17.700
17.800
17.900
1800
18.100
18.200
18.300
18.400
18.500
18.600
18.700
18.800
18.900
1900
19.100
19.200
19.300
19.400
19.500
19.600
19.700
19.800
19.900
2000
20.100
20.200
20.300
20.400
20.500
20.600
20.700
20.800
20.900
2100
21.100
21.200
21.300
21.400
21.500
21.600
21.700
21.800
21.900
2200
22.100
22.200
22.300
22.400
22.500
22.600
22.700
22.800
22.900
231300
23.11600
23.200
23.300
23.400
23.500
23.600
23.700
23.800
23.900
2400
24.100
24.200
24.300
24.400
24.500
24.600
24.700
24.800
24.900
2500
25.100
25.200
25.300
25.400
25.500
25.600
25.700
25.800
25.900
2600
26.100
26.200
26.300
26.400
26.500
26.600
26.700
26.800
26.900
2700
27.100
27.200
27.300
27.400
27.500
27.600
27.700
27.800
27.900
2800
28.100
28.200
28.300
28.400
28.500
28.600
28.700
28.800
28.900
2900
29.100
29.200
29.300
29.400
29.500
29.600
29.700
29.800
29.900

Exponential: the retries stay synchronised, so the recovering server's worst 100 ms saw 165 requests (it can answer 20) against 12 with full jitter. At 30 s, 280 clients were still failing; with jitter everyone was served by 17.5 s.

How the model works

Exponential delays are 1, 2, 4, 8 s, then 8 s (capped); full jitter waits a random time between 0 and that. Past its capacity the server wastes time on rejections, so it answers fewer. Each run uses a seeded random stream: reshuffle for another.

SELECT id, payload FROM jobs
WHERE run_at <= now() AND done_at IS NULL
ORDER BY run_at
LIMIT 50
FOR UPDATE SKIP LOCKED;

Backpressure and monitoring

1,000 messages arrive each second and workers handle 800.

Read more · 590 words

The backlog grows to 720,000 after an hour, and a new arrival waits 15 minutes. A queue never fixes a rate mismatch; it only hides it for a while.

: items in a system = arrival rate x time in it. It sizes fleets: 1,000 jobs/s x 200 ms = 200 jobs in progress, so at least 200 worker slots. Add margin: at 70% utilisation you want about 290.

The arithmetic: 200 extra per second x 3,600 s = 720,000. A new arrival waits 720,000 / 800 per second = 900 s, which is 15 minutes.

is your answer when can't keep up. Four tools, and you often use two.

ToolHow it worksUse it forWatch out for
Bound the queueSlow producers when it is full: answer 429 or 503 with Retry-After. The backlog stops growingPublic APIs and producers you do not controlSome requests are refused. That is the point
Autoscale on queue depthScale on depth or the age of the oldest message, not CPU: workers waiting on a slow downstream look idle on CPU while the backlog climbsBursts you can outrun. A new worker takes a minute to start, but the queue holds the spike meanwhileCap the fleet so you do not stampede the database behind it
Shed low-value workDrop or delay cheap work first: a marketing push can wait an hour, a password reset cannotOverloadDecide the order before the incident, not during it
One queue per priorityA million-message campaign in a shared queue sits in front of every login codeUrgent plus bulk workNeeds separate queues and separate workers

A queue turns a spike into delay instead of errors. Say workers handle 2,000/s, normal traffic is 1,000/s, and a flash sale sends 5,000/s for 60 s. The backlog grows by 3,000/s x 60 = 180,000. After the sale it drains at 2,000 - 1,000 = 1,000/s net, so it takes 180 s to clear. At the peak, a new job waits 180,000 / 2,000 = 90 s.

Add workers or make jobs faster in the simulator, then hit the spike.

What to monitor. Four numbers tell you whether async work is healthy. Depth alone misleads, because 10,000 waiting messages is fine at 5,000/s and an outage at 5/s.

SignalAlert whenIt means
Age of oldest messageAbove your deadline (say 60 s for email)Consumers cannot keep up
Queue depth (Kafka: lag)Rising for 5 minutesBacklog growing
DLQ depthAbove zeroA poison message needs a human
Consumer error rateAbove baselineA dependency is failing

Age is the one users feel. In the flash sale above, the peak depth is 180,000 but the number to alert on is the 90 s wait. Peak backlog 180K, drain time 180 s at +1,000/s net, depth target 0.

Arriving
1,000/s
Processed
800/s
Backlog growth
+200/s
After 1 hour
720K
messages waiting

Press Play to watch it run · 3 steps

When producers outpace consumers, bound the queue and push back, add consumers on queue depth, or drop low-value work.

Three answers to a growing backlogWhen producers outpace consumers, bound the queue and push back, add consumers on queue depth, or drop low-value work.calljobtakeClientAPIQueuelimit 100KWorkers×4

Tap any box to see what it is and what it talks to.

  • Client
  • Service
  • Queue
  • Worker
Open in playground
Text description
  1. Jobs arrive faster than the workers finish them, and the queue fills to its limit.
  2. A new request arrives. The API checks the depth before publishing.
  3. It answers 429 with Retry-After: 5. The backlog stops growing and callers wait their turn instead of the queue swallowing everything.
Interactive

Priority lanes

Login codes (OTP) must arrive in seconds; marketing can wait hours. Give them separate queues and workers, or watch one campaign bury every login.

OTP lanecritical · login codesempty2 workers idleMarketing lanelow priorityempty8 workers idle
  • OTP code (one every 0.5 s)
  • marketing (log-scale bar)
  • 1 worker = 20 messages/s
OTP sent now waits
50 ms ✓ on time
Oldest waiting OTP
none (0 delivered)
Marketing backlog
0 keeping up
Simulated time
0 ms (10× real time)

Separate lanes: login codes go out in 50 ms. Launch the campaign and watch them stay fast.

Speed and OTP wait chart
Speed

The OTP latency chart fills in as the simulation runs.

Backlog during a 60 s flash sale (messages waiting)
050K100K150K200Kmsgs03060120180240300Seconds
Show data table
Backlog during a 60 s flash sale (messages waiting)
SecondsBacklog (msgs)
00
3090000
60180000
120120000
18060000
2400
3000
Interactive

Queue simulator

When producers outpace consumers the backlog grows without limit. Add workers or make jobs faster, then hit the spike.

Producer30/s0 waiting● emptybacklog,last 60 s0/4 workers busytop 10
Backlog
0 jobs
steady
Arrivals / capacity
30 / 40
jobs/s · 4 × 1000 ÷ 100
Utilisation
0%
0 of 4 workers
Wait (Little's law)
0 ms
backlog ÷ throughput

Queue empty: 4 workers finish up to 40 jobs/s and 30 arrive, so utilisation is 0% and jobs barely wait.

Chart: queue depth over time
Queue depth over time
00.30.50.81jobs0Time (s)
Show data table
Queue depth over time
Time (s)Queue depth (jobs)
00

Quick checkWhich queue metric do users feel most directly?

Outbox and sagas

"Save the order, then publish order.created" can't be atomic: the database and the share no .

Read more · 833 words

Crash in between: the order exists, the event is lost, and nothing is charged or shipped. Publish first: if the transaction rolls back, act on an order that does not exist. At 100 orders per second, if just 0.01% of requests die in that window, you lose about 1 order every 100 seconds. It never throws an error, which is why it survives to production.

The (the default) writes the order and an outbox row in one local transaction. A relay then publishes unpublished rows. A crash can send a row twice, so consumers by event id. The event is late at worst, never lost.

Use it whenever a state change must also produce an event: orders, payments, account changes. The relay polls every 100 ms to 1 s, so events trail the commit by about that much.

  • CDC (change data capture), for example Debezium, reads the database and publishes each committed outbox row: no query, no published_at to update, events in commit order. Use it when you need milliseconds, or polling load on a busy table hurts. The cost is more infrastructure (a connector, usually on Connect) and a trap: a dead connector's slot makes Postgres keep all WAL, and the disk fills.
  • Duplicates: the relay can publish a row twice after a crash, so consumers dedupe by event id.
  • Growth: delete published rows after a day or so, or the table and its index bloat.
  • Lag: alert on the age of the oldest unpublished row; past a minute, the relay is stuck. With several relays, claim rows with FOR UPDATE SKIP LOCKED and key the events by entity so order still holds per key.

A purchase spans orders, inventory, payments and shipping, each with its own database. A chains local transactions, each with a compensating action that undoes it:

  1. Orders: create the order as PENDING

    Compensation: mark it cancelled.

  2. Inventory: reserve the stock

    Compensation: release the reservation.

  3. Payments: charge the card

    Compensation: refund. If the charge fails, undo in reverse.

  4. Shipping: create the shipment

    If this fails, refund, release and cancel. If it works, mark the order CONFIRMED.

Who drives the steps is the choice.

StyleHow it worksUse it forWatch out for
ChoreographyNo coordinator: each service reacts to the previous service's event and publishes its own. Loose coupling, nothing central to run2 or 3 steps with simple failuresThe flow lives in many services: to answer "where is order 17 stuck?" you read several codebases and trace events
OrchestrationA coordinator holds the whole flow, calls each service in turn and runs compensations in reverse on failure. One place to time out, retry and monitor4 or more steps, or whenever you must answer for the state of every order. Tools: Temporal, AWS Step FunctionsOne more service to run; it becomes a god object if business rules leak into it

A saga has no isolation. Between reserve stock and charge card, another shopper sees the item as out of stock. Show a PENDING state, and expire reservations. Compensations can fail too, so make them and retry them until they succeed: a business undo (a refund), not a delete.

The numbers: if each of 4 steps succeeds 99.9% of the time, the saga completes 0.999 to the fourth, about 99.6%. One order in 250 needs at least one compensation, so compensation is a normal path, not an edge case. across services is the alternative, but it blocks while the coordinator is down, so teams rarely use it.

Press Play to watch it run · 8 steps

The order and its event commit in one local transaction. A relay publishes unpublished outbox rows to Kafka, and consumers dedupe by event id.

Transactional outboxThe order and its event commit in one local transaction. A relay publishes unpublished outbox rows to Kafka, and consumers dedupe by event id.BEGIN; INSERT orderINSERT outbox row; COMMITOne transaction: both rows or neithercommittedalt [commit ok]SELECT … SKIP LOCKEDevent rowspublish order.created (id)SET published_at = now()deliver (at least once)Dedupe by event id
  • Service
  • Database
  • Worker
  • Queue
Text description
  1. Orders API → Postgres: BEGIN; INSERT order
  2. Orders API → Postgres: INSERT outbox row; COMMIT
  3. Postgres replies to Orders API: committed
  4. Outbox relay → Postgres: SELECT … SKIP LOCKED
  5. Postgres replies to Outbox relay: event rows
  6. Outbox relay sends (async) to Kafka: publish order.created (id)
  7. Outbox relay → Postgres: SET published_at = now()
  8. Kafka sends (async) to Consumers: deliver (at least once)
CREATE TABLE outbox (
  id           bigserial PRIMARY KEY,
  topic        text NOT NULL,
  payload      jsonb NOT NULL,
  published_at timestamptz
);
CREATE INDEX outbox_pending ON outbox (id) WHERE published_at IS NULL;

BEGIN;
INSERT INTO orders (id, user_id, total_cents) VALUES ($1, $2, $3);
INSERT INTO outbox (topic, payload) VALUES ('order.created', $4);
COMMIT;

Press Play to watch it run · 5 steps

A coordinator holds the whole flow, calls each service in turn and runs the compensations in reverse on failure.

Saga by orchestrationA coordinator holds the whole flow, calls each service in turn and runs the compensations in reverse on failure.reservechargeshipOrchestratorsaga stateInventoryPaymentsShipping

Tap any box to see what it is and what it talks to.

  • Service
Open in playground
Text description
  1. The orchestrator tells Inventory to reserve the stock.
  2. Reserved. The orchestrator records the step.
  3. Next it tells Payments to charge the card.
  4. Charged.
  5. Finally it creates the shipment and marks the order CONFIRMED.

Code

idempotent-consumer.js fixes a classic bug: marking a message "done" before sending, so a failed email was skipped on every retry.

Read more · 68 words

outbox-relay.js uses FOR UPDATE SKIP LOCKED, so parallel relays never publish the same row.

// At-least-once delivery means duplicates. Claim → do the work → mark done.
// On failure, release the claim so the retry actually runs (the classic version marked "done" first,
// so a failed send was skipped as a "duplicate" on every retry and never delivered).
export async function handle(msg) {
  const doneKey = `done:${msg.id}`, lockKey = `processing:${msg.id}`;
  if (await redis.exists(doneKey)) return;                               // already delivered
  if (!(await redis.set(lockKey, 1, 'EX', 60, 'NX'))) return;            // another worker is on it
  try {
    await sendEmail(msg);                                                // pass msg.id to the provider as an idempotency key too
    await redis.set(doneKey, 1, 'EX', 7 * 86_400);                       // only now is it "done"
  } catch (err) {
    const attempts = msg.attempts ?? 0;
    if (attempts >= 4) await queue.publish('email.dlq', { ...msg, error: String(err) });   // 5th failure: park it
    else await queue.publish('email.retry', { ...msg, attempts: attempts + 1 }, { delayMs: 2 ** attempts * 1_000 });   // 1 s, 2 s, 4 s, 8 s
  } finally {
    await redis.del(lockKey);
  }
}

Async messaging in the interview

Bring up a queue when slow, flaky or spiky work sits in the request path. Then walk five decisions in order.

Read more · 645 words
  1. Decide what leaves the request path

    Only what the user does not need in the response: email, transcode, . Say what stays sync.

  2. Pick the tool

    A work queue (SQS) for jobs; when several services read the events or need replay.

  3. State the delivery guarantee

    with after processing, and : a table keyed by message id.

  4. Plan for failure

    with , about 5 attempts, a with an alert, and (bounded queue, on depth).

  5. Close the dual write

    An for any event that must go with a database change; a with compensations if it spans services.

If the interviewer saysYou raise
"Send a notification after signup"A queue and a worker; API returns fast, worker retries
"Millions of events, many consumers"Kafka, partitions keyed by entity, one group per consumer
"What if the consumer crashes mid-job?"At-least-once redelivery, so an idempotent consumer
"Events for one order must be in order"Key by orderId; retry in place
"Traffic spikes 5x on a sale"The queue absorbs it; autoscale on depth; 429 at the edge
"Save the order and tell other services"Transactional outbox, not write then publish

Quick checkWhich delivery guarantee do you state first for a job queue?

Interview questions

How do you avoid processing a message twice?

Make the : record the message id under a unique constraint, in the same .

How do you keep events for one user in order?

Key by userId, so one partition and one consumer hold them in order.

Kafka or RabbitMQ/SQS?

for streams that many groups read and replay; RabbitMQ or SQS for task queues.

What is backpressure?

Slowing producers when consumers can't keep up: bounded queues, 429/503.

Why an outbox instead of “write the DB, then publish”?

A crash in between loses the event. The commits it with the data.

A message fails every time. What happens to it?

Retry with up to about 5 attempts, then move it to a and alert. Fix the bug, then redrive it.

Your consumer crashes after processing but before the ack. What now?

The redelivers, so the consumer sees it twice. A table or an idempotent write makes the second pass harmless.

Check yourself

Question 1 of 5

Score 2 out of 3 or better to mark this page complete.

1. A topic has 6 partitions and a consumer group has 8 consumers. How many consumers sit idle?

Next: With many nodes and copies of data, you must decide how consistent they need to be.