What you'll learn: By the end of this stage you will be able to split checkout into a fast synchronous part and a reliable asynchronous pipeline, choose between a queue and an event log, state what each delivery guarantee means (including the scope of Kafka's idempotent producer and transactions), write an outbox and an idempotent consumer, design a saga, run safe background jobs, and apply backpressure.
When you post a parcel, the clerk weighs it, takes your money and hands you a tracking number. You do not wait while the parcel crosses the country. The journey is a chain of recorded hand-offs, and if a leg fails the parcel is re-routed or returned.
QuickCart's checkout needs the same shape. Stage 4 let the orders database take many writes by sharding it by shopper id. It did not change how checkout is built: one request still does everything, in order, while the shopper waits. This stage replaces that chain with a pipeline.
Where QuickCart is now
After Stage 4, orders live on shards chosen by shopper id and every order has a unique id. Checkout is still one synchronous request that does six things in line:
Step
What it does
Illustrative time
1
Validate the cart and re-check prices
50 ms
2
Reserve stock at the chosen store
100 ms
3
Charge the card through the external payment gateway
900 ms
4
Assign a courier
400 ms
5
Write the order to the shopper's shard
50 ms
6
Send the confirmation push and SMS
300 ms
The times are illustrative assumptions and sum to 1,800 ms. The target peak from Stage 1 is about 58 checkouts per second, so about 58 x 1.8 = 104 checkouts are in flight at once, each holding a connection and a transaction slot.
Checkout also depends on five things at once: stock, the gateway, courier assignment, the notification provider and the orders database. If each is available 99.9 percent of the time (illustrative), all five work 0.999 to the fifth power of the time, about 99.5 percent. That is roughly 3.6 hours a month of failed checkouts, mostly from steps the shopper does not need to wait for.
Figure 1. Synchronous checkout when the payment gateway is slow
sequenceDiagram
accTitle: Synchronous checkout with a slow payment gateway
accDescr: The app server reserves stock, calls the payment gateway, times out with no reply, and returns an error. The shopper retries while the first charge may have succeeded.
participant S as Shopper
participant A as App server
participant K as Stock
participant G as Payment gateway
S->>A: POST checkout
A->>K: reserve stock
K-->>A: reserved
A->>G: charge card
Note over A,G: gateway is slow, 30 s timeout
G--xA: no reply
A-->>S: error, outcome unknown
S->>A: shopper retries checkout
Note over G: the first charge may have succeeded
How to read this: the timeout means "I do not know", not "it failed". The stock is still reserved, the card may be charged, and the retry starts the chain again, so one order can be paid for twice.
Latency is the sum of the steps, availability is their product, and a timeout leaves the outcome unknown. The fix is to change the promise: not "your order is done" but "your order is safely accepted, and here is where to watch it".
The core idea
Accept the order fast and durably, then finish it as a chain of retryable steps whose progress is stored. Three tools make that safe: a durable broker between the steps (a queue for tasks, a log for events); the transactional outbox, so the order and the message announcing it are saved together or not at all; and a saga, so that when a later step fails, earlier ones are undone by compensating actions. The rest of the lesson is the discipline that keeps those tools working when things go wrong.
Synchronous and asynchronous communication
Synchronous versus asynchronous
In a synchronous call the caller sends a request and waits for the answer. Both sides must be up at the same moment, and the caller's latency includes the callee's. In an asynchronous exchange the caller hands the work to a durable intermediary (a queue, a log, a table), gets a quick acknowledgement that it was stored, and carries on. The result arrives later by another path.
Asynchronous does not make the work faster. It removes slow steps from the shopper's wait and stops one step's failure from failing the others. The price: results are eventual, messages can arrive twice, and a step that fails later must be undone, not just reported. The rule from message queues applies: do synchronously whatever the user must see or the system must verify before the response, and queue the rest.
Figure 2. Should this checkout step stay synchronous?
flowchart TD
accTitle: Deciding whether a checkout step is synchronous or asynchronous
accDescr: A step stays synchronous if it decides whether the order can be accepted or is fast and local; otherwise it becomes a saga step if it can be undone, or a retried job after the saga if it cannot.
S["A checkout step"] --> Q1{"Must it pass before the order is accepted?"}
Q1 -->|yes| Sync["Keep it synchronous and fast"]
Q1 -->|no| Q2{"Is it slow or run by a third party?"}
Q2 -->|no| Sync
Q2 -->|yes| Q3{"Can it be undone if a later step fails?"}
Q3 -->|yes| Saga["Saga step with a compensation"]
Q3 -->|no| Job["Retried job after the saga succeeds"]
How to read this: only checks that decide whether QuickCart can accept the order stay in the request. Slow steps move out; reversible ones become saga steps, and irreversible ones (an SMS already sent) run after everything else has succeeded.
Step
Decision
Reason
Validate cart and prices
Synchronous
A bad cart is rejected now
Write order and outbox row
Synchronous
The one thing "accepted" depends on
Reserve stock
Asynchronous, saga step 1
Undone by releasing the hold
Authorise payment
Asynchronous, saga step 2
Slow and external; undone by voiding
Assign courier
Asynchronous, saga step 3
Can fail; undone by release
Confirmation push and SMS
Asynchronous, after the saga
Cannot be undone, so it runs last
The response becomes 202 Accepted with the order id and a status link. The app shows "Placing your order" until the pipeline reports "Confirmed" or "Could not be placed", ideally within seconds (an objective to measure).
Moving work through brokers
Message queues and publish/subscribe
A message queue is a durable buffer between a producer and a pool of consumers. A consumer receives a message, does the work and acknowledges it; only then is it removed. If the consumer crashes first, the message becomes visible again after a timeout and another consumer takes it. When several consumers read one queue, each message goes to one of them: they compete, which spreads load and lets you add workers.
Publish/subscribe (pub/sub) sends a message to a topic, and every subscriber receives its own copy. Use a queue for a task ("reserve stock for order 9001") and a topic for a fact ("order 9001 was placed"): a task has one doer, a fact has many readers. By convention a command is imperative and addressed to one owner (ReserveStock); an event is past tense and addressed to nobody (OrderPlaced). QuickCart uses both: events flow through a log so many consumers can read them, and the orchestrator sends commands through queues so one worker does each task.
Figure 3. Competing consumers for commands, fan-out for events
How to read this: in the top lane, adding stock workers shares one queue's load. In the bottom lane, adding a consumer group adds a new reader of the same events and leaves the others unaffected.
Event streaming with a Kafka-style log
A queue deletes a message once acknowledged. An event log keeps it. This section uses Apache Kafka's vocabulary and its official documentation.
A topic is a named stream of one kind of event, here order-events. It is split into partitions, each an ordered, append-only log. Events with the same key go to the same partition.
Each event gets a sequential offset (0, 1, 2, ...) within its partition; offsets mean nothing across partitions.
A consumer group shares a topic: each partition is read by exactly one consumer in the group at a time. Each group keeps its own position, the offset of the next event it will read, and can deliberately rewind to re-read.
Retention is set per topic. Consuming does not delete events; they are discarded when the retention time or size limit passes.
Ordering holds inside a partition only. There is no ordering guarantee between partitions.
QuickCart's key is the order id: all events for one order land in one partition and are read in order, while different orders spread across partitions. Suppose partition 2 holds these events (illustrative):
Offset
Key (order)
Event
118
o-7001
OrderPlaced
119
o-7001
StockReserved
120
o-7344
OrderPlaced
121
o-7001
PaymentAuthorised
122
o-7344
StockReserved
123
o-7001
CourierAssigned
The next event gets offset 124, the log end offset. If the notifications group has committed offset 121 and analytics has committed 124, notifications is 3 events behind and analytics is caught up. That difference is the consumer lag, the main health signal of a stream, tracked per group and partition.
Figure 4. Three of the twelve partitions of order-events, one consumer group
How to read this: the relay routes each event by the hash of its order id to one partition. Inside the group each partition has one reader, so a third orchestrator instance would take a partition from one of the others.
QuickCart's settings, all illustrative and derived from the shared numbers:
Setting
Value
Why
Partitions
12
Headroom for up to 12 parallel orchestrators; throughput is not the limit
Peak event rate
about 350 per second
58 checkouts per second x 6 events per order
Daily volume
about 3 GB
500,000 orders x 6 events x 1 KB
Retention
7 days
Longer than the worst consumer outage
Disk for retention
about 63 GB
3 GB x 7 days x 3 replicas
Caution: changing the partition count of an existing topic changes how keys map to partitions and can break per-key order, so provision headroom. A skewed key (a store id, say) makes a hot partition; order id avoids it. More in event streaming.
Delivery guarantees
At-most-once: zero or one delivery. The message is marked done before the work, so a crash loses it. Fit only for data you can afford to lose.
At-least-once: one or more deliveries. It is marked done after the work, so a crash or lost acknowledgement causes redelivery. Assume this by default.
Exactly-once: one effect. Between independent systems over a network, a sender cannot tell a lost message from a lost acknowledgement, so exactly-once delivery cannot be assumed. What can be built is exactly-once effect: at-least-once delivery plus idempotent processing, or an atomic transaction inside one system.
In Kafka, "marked done" is committing the consumer's offset. Commit before processing and a crash skips events; process, then commit, and a crash re-reads them. Kafka also has two features often called exactly-once, each with a limited scope:
Feature
What it gives you
What it does not cover
Idempotent producer (enable.idempotence; on by default unless settings conflict, per the 4.4 producer docs at the time of writing)
The broker deduplicates a producer's retried sends using a producer id and per-partition sequence numbers
A new producer instance gets a new producer id, so a message your application resends after a restart is not recognised. The guarantee is per producer session
Transactions (a configured transactional.id)
Atomic writes across partitions and topics, with consumed offsets committed in the same transaction, so a consume-process-produce loop inside Kafka takes effect once. Consumers need isolation.level=read_committed to skip aborted data
Anything outside Kafka. The docs say exactly-once for other destinations needs their cooperation: a database write, an email or a payment call is not in the transaction
QuickCart's orchestrator writes to PostgreSQL and calls queues and a gateway, so Kafka transactions do not cover it. The rule for the pipeline: every hop is at-least-once, and every consumer is idempotent.
The first hop (API to shard) is one local transaction. The relay can republish, so every event carries a unique event_id. Queue workers get the order id as a business key, and the payment worker sends an idempotency key to the gateway (Stage 6).
The consumer stores a record of handled events in the same transaction as its change; a unique key makes a duplicate harmless:
CREATE TABLE inbox (
consumer text NOT NULL,
event_id uuid NOT NULL,
handled_at timestamptz NOT NULLDEFAULT now(),
PRIMARY KEY (consumer, event_id)
);
BEGIN;
INSERT INTO inbox (consumer, event_id) VALUES ('orchestrator', $1)
ON CONFLICT DO NOTHING;
-- If that inserted 0 rows, the event was already handled: COMMIT and acknowledge.UPDATE orders SET status ='STOCK_RESERVED'WHERE order_id = $2AND status ='PENDING';
COMMIT;
If the consumer crashes after the commit but before acknowledging, the redelivered event finds its inbox row and does nothing. The status = 'PENDING' guard also stops a late, old event from moving an order backwards.
Dead-letter queues and retries
A transient failure (the gateway returned 503) usually succeeds on retry. A permanent failure (a malformed message) never does. A message that always fails is a poison message; retried forever, it burns capacity and blocks others. The standard design:
Classify the error in the handler. Permanent errors go straight to the dead-letter queue; timeouts and 5xx responses are retried.
Retry with exponential backoff and jitter. The wait is a random value up to a ceiling that doubles each attempt and is capped. With a 1-second base and a 60-second cap (illustrative) the ceilings are 2, 4, 8, 16 and 32 seconds. The randomness stops failed messages retrying together; see reliability basics.
Limit attempts, then dead-letter. The message moves to a dead-letter queue (DLQ). Managed queues express the limit their own way: Amazon SQS uses a redrive policy with a maxReceiveCount, and advises a longer retention on the DLQ than on the source queue.
Alert and redrive. Alert on DLQ depth, because an unwatched DLQ is where orders are forgotten. A redrive tool moves messages back to the source after the fix.
Figure 5. What a worker does with one message
flowchart TD
accTitle: Retry and dead-letter decision for one message
accDescr: A message succeeds and is acknowledged, fails permanently and goes to the dead-letter queue, or fails transiently and is retried with backoff until attempts run out.
M["Message received"] --> H{"Handler result"}
H -->|"success"| Ack["Acknowledge and delete"]
H -->|"permanent error"| DLQ["Dead-letter queue now"]
H -->|"transient error"| R{"Attempts left?"}
R -->|"yes"| B["Wait with backoff and jitter"]
R -->|"no"| DLQ
B --> M
DLQ --> A["Alert, inspect, redrive after the fix"]
How to read this: only transient errors are retried. A permanent error skips the retries, because retrying cannot help.
On a log, Kafka's consumer groups have no per-message DLQ in the broker (Kafka Connect and newer share groups add their own forms), and a failing event blocks its partition. The usual pattern is for the consumer to publish the failed event to a retry or dead-letter topic and move on, accepting out-of-order handling; that is why guarded updates matter. If a downstream is down for long, pause consumption rather than burn attempts.
Writing the order and the event together
The transactional outbox pattern
Checkout must save the order and announce it, in two systems: the orders database and the broker. This is the dual-write problem, and no ordering is safe. Save, then publish: a crash in between leaves an order nobody processes. Publish, then save: a crash or a failed save announces an order that does not exist.
The transactional outbox writes the message into the same database as the business change. One local transaction inserts the order and an outbox row describing the event; a separate relay later publishes unpublished rows and marks them sent.
CREATE TABLE outbox (
seq bigserial PRIMARY KEY,
event_id uuid NOT NULLUNIQUE,
aggregate_id text NOT NULL,
event_type text NOT NULL,
payload jsonb NOT NULL,
created_at timestamptz NOT NULLDEFAULT now(),
published_at timestamptz
);
CREATE INDEX outbox_unpublished ON outbox (seq) WHERE published_at ISNULL;
BEGIN;
INSERT INTO orders (order_id, shopper_id, store_id, status, placed_at)
VALUES ($1, $2, $3, 'PENDING', now());
INSERT INTO outbox (event_id, aggregate_id, event_type, payload)
VALUES ($4, $1, 'OrderPlaced', $5::jsonb);
COMMIT;
Either both rows exist or neither does. The relay loops: select the oldest unpublished rows, publish each to Kafka with the order id as key, wait for the acknowledgement, and set published_at.
Figure 6. Checkout writes the order and the outbox row together
sequenceDiagram
accTitle: The transactional outbox
accDescr: The checkout API inserts an order and an outbox row in one transaction. A relay later reads the unpublished row, publishes it to Kafka, and marks it published.
participant A as Checkout API
participant D as Orders shard
participant R as Outbox relay
participant K as Kafka
A->>D: BEGIN
A->>D: INSERT order, status PENDING
A->>D: INSERT outbox row OrderPlaced
A->>D: COMMIT
A-->>A: respond 202 Accepted
R->>D: read unpublished rows
D-->>R: OrderPlaced
R->>K: publish, key = order id
K-->>R: acknowledged
R->>D: set published_at
How to read this: the shopper's 202 depends only on the first four steps. If the relay crashes before the last step, the row is simply published again.
Four points make it correct in practice:
The outbox sits on the same shard as the order. A transaction cannot span shards (Stage 4), so each shard has its own outbox and relay.
Publication is at-least-once. A relay that crashes after publishing republishes, so consumers deduplicate by event_id.
Run one active relay per shard, since two relays polling the same rows could publish out of order. It is a leader-run process (see background jobs).
Order is per aggregate and not perfect: sequence numbers can commit out of order, so carry a version in the payload if consumers must detect gaps. A scheduled job deletes old published rows.
Change data capture (reading the write-ahead log) is the alternative to polling: lower latency, more infrastructure. Polling suffices at 58 checkouts per second. See distributed transactions.
Coordinating the steps
The saga pattern
Stock, payment and courier have different owners and data, and a two-phase commit would block on the external gateway. A saga replaces the one big transaction with a sequence of local transactions, one per step. Each step has a compensating action that semantically undoes it. If step N fails, the saga runs the compensations for steps N-1 down to 1.
Step
Local transaction
Compensation
Notes
1
Reserve stock: hold the items at the store
Release the hold
The hold expires by itself (illustrative: 10 minutes) if the saga stalls
2
Authorise payment: the gateway holds the funds
Void the authorisation
After a later capture it would be a refund (Stage 6)
3
Assign a courier
Release the courier
Can fail for reasons outside the shopper's control
4
Mark the order CONFIRMED
None
Only forward steps remain
Compensation is semantic, not a rollback: a void is a new, visible action, and it can fail too. So it must be idempotent and retried until it succeeds, and a compensation that keeps failing goes to a manual-repair queue with an owner.
Orchestration or choreography? In orchestration a central orchestrator tells each service what to do and records each order's state: the flow is visible in one place, but the orchestrator must be reliable and free of business rules. In choreography services react to each other's events (StockReserved triggers payment): no controller, but the flow is implicit across services and hard to debug. Choreography suits two or three steps; past that, orchestration usually pays for itself.
QuickCart uses orchestration, because it needs to answer "where is order 9001 stuck?" in seconds. The orchestrator is a consumer group on order-events. For each event it loads the order, applies the next transition with a guarded update, and sends the next command to a queue. Workers reply with events published through their own outbox.
Figure 7. The life of an order as a state machine
stateDiagram-v2
accTitle: QuickCart order saga states
accDescr: An order moves from pending through stock reserved, payment authorised and confirmed. A failure at any step moves it to compensating and then cancelled. A compensation that keeps failing moves it to needs review.
[*] --> PENDING: checkout accepted
PENDING --> STOCK_RESERVED: stock held
STOCK_RESERVED --> PAYMENT_AUTHORISED: funds held
PAYMENT_AUTHORISED --> CONFIRMED: courier assigned
PENDING --> CANCELLED: stock unavailable
STOCK_RESERVED --> COMPENSATING: payment declined
PAYMENT_AUTHORISED --> COMPENSATING: no courier in time
COMPENSATING --> CANCELLED: undo steps done
COMPENSATING --> NEEDS_REVIEW: undo keeps failing
CONFIRMED --> [*]
CANCELLED --> [*]
How to read this: states above CONFIRMED can still be undone, and each failure arrow leads to compensation. CONFIRMED is the point of no return. NEEDS_REVIEW is where a human takes over, because compensations can fail.
A saga gives up isolation: other readers see half-done orders. QuickCart makes the states honest ("Placing your order") and uses holds with expiry so a stalled saga cannot lock stock forever. Every saga has a deadline: an order stuck in a non-final state past it is a defect that a scheduled job finds. The payment side is in Stage 6.
Running work in the background
Background jobs and schedulers
A background job runs outside a request. QuickCart has three kinds: cron-like jobs on a schedule (purge published outbox rows, find stuck orders, later reconcile payments); delayed jobs set by an event for a future time ("if order o-7001 is still PENDING after 10 minutes, illustratively, cancel it"); and retried jobs rescheduled with backoff.
Jobs must be idempotent. Kubernetes' CronJob documentation says a CronJob creates a Job approximately once per scheduled time, that two Jobs might be created or none, and that Jobs should therefore be idempotent. "Cancel orders still PENDING older than 10 minutes" is idempotent, since a second run finds nothing to do. "Add 10 loyalty points" is not, until it carries a deduplication key.
A leader-run task must have one runner. The outbox relay and the stuck-order scanner must not run on every instance at once. Options: a CronJob with concurrencyPolicy: Forbid (skip a run if the previous is still going; Allow is the default), a lease row in the database that one instance holds and renews, or a leader chosen by a consensus service (consensus and leader election). A lease can expire while its holder works, so the job must still tolerate a second runner.
QuickCart keeps delayed jobs in a table, claimed with SKIP LOCKED. PostgreSQL's documentation notes that skipping locked rows gives an inconsistent view, unsuitable for general work but fine for queue-like tables with several consumers:
CREATE TABLE jobs (
job_id uuid PRIMARY KEY,
job_type text NOT NULL,
dedup_key text NOT NULLUNIQUE,
payload jsonb NOT NULL,
run_at timestamptz NOT NULL,
status text NOT NULLDEFAULT'queued',
attempts intNOT NULLDEFAULT0,
locked_until timestamptz
);
-- Claim up to 20 due jobs; a crashed worker's lease expires after 60 secondsUPDATE jobs
SET status ='running', attempts = attempts +1,
locked_until = now() +interval'60 seconds'WHERE job_id IN (
SELECT job_id FROM jobs
WHERE run_at <= now()
AND (status ='queued'OR (status ='running'AND locked_until < now()))
ORDERBY run_at
LIMIT 20FORUPDATESKIP LOCKED
)
RETURNING job_id, job_type, payload;
When checkout writes order o-7001 it also inserts a job with dedup_key = 'expire-order:o-7001' and run_at = now() + interval '10 minutes'; with ON CONFLICT (dedup_key) DO NOTHING, inserting it twice is harmless. When it runs, it sends CancelOrder to the orchestrator, which ignores it if the order is already final. At this volume a table is enough.
Staying alive under load
Backpressure
A queue hides a speed mismatch; it does not remove it. If messages arrive faster than consumers finish them, the backlog and the waiting time grow. Backpressure pushes that pressure back toward the source or sheds load before things break:
Bounded queues. Give each queue a maximum depth. An unbounded queue turns overload into a slow outage of stale work.
Load shedding. When the queue is full, refuse new work early and clearly (503 with Retry-After, or "high demand, try again shortly"), least valuable work first. Never silently drop work you already acknowledged.
Lag and age as signals. Alert on the age of the oldest message and on consumer lag, ideally in seconds. Depth alone misleads: a deep queue draining fast is fine; a shallow one with an old message is not.
A worked example, with illustrative numbers. The gateway contract allows 40 authorisations per second, and a promotion pushes checkouts to the 58 per second peak for 10 minutes. The surplus is 58 - 40 = 18 per second. Over 600 seconds the backlog reaches 18 x 600 = 10,800, and draining it at 40 per second takes another 270 seconds if nothing new arrives, so the last shopper waits over four minutes. If shoppers tolerate about 60 seconds, the payment queue should hold at most 40 x 60 = 2,400 messages. At 18 extra per second it fills in about 133 seconds, and checkout then sheds the excess with a clear message.
Shedding is the safety net, not the plan: here about 8,400 checkouts would be turned away, so the real fix is a higher limit or a second provider. Kafka does not slow producers because consumers lag, so the limit belongs at the checkout API (rate limiting).
Signal
Source
Illustrative alert
First action
Oldest unpublished outbox row
Orders shard
Older than 30 s
Check the relay and the broker
Oldest message, payment queue
Queue
Older than 60 s
Scale workers within the gateway limit; shed at the API
DLQ depth
Queue
Above 0 for 5 minutes
Inspect the messages
Order past its deadline
Orders shards
Any
Stuck-order runbook
One checkout, end to end
Order o-7001 goes through the whole pipeline, with a failure: no courier is free.
Figure 8. One checkout with a failure and its compensation
sequenceDiagram
accTitle: QuickCart checkout through the pipeline with a courier failure
accDescr: Checkout writes the order and outbox. The relay publishes OrderPlaced. The orchestrator reserves stock and authorises payment, then courier assignment fails, so it voids the payment, releases the stock, cancels the order, and the shopper is notified.
participant S as Shopper
participant A as Checkout API
participant D as Orders shard
participant E as Relay and Kafka
participant O as Orchestrator
participant K as Stock
participant P as Payment
participant C as Delivery
S->>A: POST checkout with idempotency key
A->>D: insert order and outbox row
A-->>S: 202 Accepted, order o-7001
D->>E: relay publishes OrderPlaced
E->>O: OrderPlaced
Note over O,C: commands go through queues, replies come back as events
O->>K: ReserveStock
K-->>O: StockReserved
O->>P: AuthorisePayment
P-->>O: PaymentAuthorised
O->>C: AssignCourier
C--xO: CourierUnavailable after retries
Note over O: state COMPENSATING
O->>P: VoidAuthorisation
P-->>O: Voided
O->>K: ReleaseStock
K-->>O: Released
O->>D: status CANCELLED
O->>E: OrderCancelled
E->>S: notification, could not be placed
How to read this: the shopper's part ends at the 202. Every arrow to a service is a retryable command and every reply an event; the compensations run in reverse order of the steps that succeeded.
The API writes the order (PENDING) and the outbox row in one transaction and returns 202.
The relay publishes OrderPlaced (key o-7001); the orchestrator reads it and records its inbox row.
ReserveStock then AuthorisePayment go through queues; each worker replies with an event.
AssignCourier fails after retries within the assignment window (illustratively 2 minutes), so the order moves to COMPENSATING.
VoidAuthorisation and ReleaseStock are retried until they succeed; the order becomes CANCELLED and OrderCancelled is published.
The notifications group tells the shopper. Nothing is charged or held.
Had a courier been found, the order would reach CONFIRMED, and OrderConfirmed would trigger the confirmation message. That message goes last because it cannot be undone.
Decisions and trade-offs
Option
Gain
Cost
When it is the wrong choice
Synchronous checkout end to end
Simple; immediate answer
Latency is the sum, availability the product, timeouts ambiguous
Any chain with a slow or third-party step
202 plus asynchronous pipeline
Fast, resilient; steps fail and scale alone
Eventual results, status tracking, compensation
A step whose result the shopper must see on the same screen
Outbox with polling
Atomic, simple, no new system
Polling load, small publication delay
Very high rates or sub-second needs, where CDC fits
In practice: QuickCart at this stage
This is a realistic scenario, not a case history. It is an educational reference design, and every number is an illustrative assumption.
The situation
QuickCart is at about 5 million monthly active users, heading to 10 million. Checkout is still the chain of Figure 1. The lunchtime peak is about 30 checkouts per second and p99 checkout latency is near 5 seconds. One evening a slow gateway caused a wave of timeouts and retries; support found 40 orders paid twice in one hour (illustrative).
Starting architecture
Figure 9. The starting architecture: checkout calls five dependencies in line
How to read this: every arrow is a synchronous call inside one request, made in the numbered order. The two dashed boxes are outside QuickCart's control, and the request is as slow and fragile as the worst of the five.
Options considered
Option
What it fixes
What it costs
Risk
A. Timeouts, retries and a gateway idempotency key
Double charges, shorter hangs
A few days
Latency and availability unchanged
B. Jobs table only: save the order, workers poll
Fast checkout, no new infrastructure
A polling worker per step
Fan-out, retries and lag monitoring built by hand
C. Outbox, command queues, Kafka topic, orchestrated saga
Fast checkout, independent steps, replay, many readers
A broker and Kafka to run
Duplicates and ordering everywhere
The decision
Field
Decision record
Context
Checkout depends on five systems in line; ambiguous timeouts cause double charges and orphaned holds; notifications and analytics need the same facts.
Decision
Option C. Checkout writes the order and an outbox row and returns 202. A relay per shard publishes to order-events (12 partitions, key order id, 7 days). An orchestrator group runs the saga through command queues with DLQs. Every consumer has an inbox table. Delayed jobs live in a table. The payment queue is bounded at 2,400 and checkout sheds beyond it.
Consequences, good
Checkout depends on one local transaction; a gateway outage delays confirmations but loses nothing; new consumers add a group, not checkout code.
Consequences, bad
Shoppers see an intermediate state; the team runs Kafka, a broker, an orchestrator and relays; debugging needs correlation ids across hops.
Revisit when
The saga passes about six steps (workflow engine), outbox lag stays above its objective (CDC), or the team cannot operate Kafka (managed service).
Rollout and verification
Ship behind a feature flag. First add the outbox and relay and verify rows reach Kafka. Then run the orchestrator in shadow mode, logging what it would do while the synchronous path still does the work, and compare for a week. Move 1, 5 and 25 percent of checkouts, then all, keeping the synchronous path as a fallback. Rehearse in staging: kill the relay, kill an orchestrator mid-saga, pause the gateway stub, push a malformed message to the DLQ. The outcome must match Figure 8: no double charges, no stuck holds.
Dashboards: time from 202 to CONFIRMED, orders by state, oldest outbox row, orchestrator lag, queue age, DLQ depth and the shed rate.
The 2 a.m. failure
A deploy at 01:50 leaves the outbox relay on one shard crash-looping. Orders are accepted and shoppers get 202, but no OrderPlaced is published, so the orchestrator never sees them. The page reads "oldest unpublished outbox row older than 30 seconds on shard 7".
First three checks: the relay's pod status and logs; the backlog on the shard (SELECT count(*), min(created_at) FROM outbox WHERE published_at IS NULL;); and whether other relays can publish to Kafka, to separate a relay bug from a broker problem.
No order is lost, since each is committed with its outbox row. Mitigation: roll back or restart the relay, throttling its publish rate if the backlog is large; tell support the orders are delayed, not lost.
A second hazard: the 10-minute expiry job may fire before the relay recovers. When the late OrderPlaced arrives for an order already cancelled, the guarded update (WHERE status = 'PENDING') finds nothing to do and the inbox records the event, so the cancelled order is not revived. Follow-ups: alert on outbox age well before the expiry window and set the expiry longer than the worst-case publish delay plus the saga.
Architecture after this stage
Figure 10. QuickCart after Stage 5: the order pipeline added
How to read this: the sync arrow ends at the orders shards, and that is the shopper's whole wait. Everything in the zone is asynchronous. The earlier stages' read path (load balancer, cache, replicas, CDN) is unchanged and not repeated.
What QuickCart looks like now
Component
Why it exists
What it costs
Outbox table and relay per shard
Saves the order and its event atomically
A polling loop and a leader-run process per shard; small delay
Kafka order-events
Ordered per order, many readers, replay
A cluster to run; key and partition design; 7 days of storage
Command queues and DLQs
One worker per task, retries, poison-message isolation
A broker, DLQ owners, redrive tooling
Order orchestrator
Runs the saga; answers "where is order X?"
A critical service to keep free of business rules
Inbox tables
Make at-least-once delivery safe
An extra insert per message; cleanup
Jobs table and scheduler
Delayed cancellation, expiry, purge
Polling; idempotency in every job
Architect's notes
Draw the sync line first. Decide what the shopper must see before the response and what cannot be undone.
Treat every consumer as a retry target. If it cannot run twice with the same message and give the same result, it is not ready.
Defer what the volume does not need. At about 58 checkouts per second, a polling outbox, a jobs table and a plain orchestrator are enough.
Common mistakes
Dual writes to a database and a broker. Use the outbox.
Assuming exactly-once delivery. Kafka's idempotent producer is per session and partition, and its transactions stop at Kafka's edge. Make consumers idempotent.
Retrying forever, or without jitter. One poison message holds a worker; synchronised retries hammer a service.
Unwatched dead-letter queues. Orders sit unnoticed. Alert, and give each DLQ an owner.
Treating compensation as rollback. A void can fail. Make it idempotent, retry it, and give failures a repair queue.
Key takeaways
Keep synchronous only what the shopper must see or the system must verify. Checkout accepts the order and returns 202.
Queues carry commands, logs carry events. A queue gives each task to one worker; a topic gives each event to every group.
Assume at-least-once everywhere. Kafka's idempotent producer (per session) and transactions (inside Kafka) are limited; consumer idempotency is the general answer.
The outbox solves the dual write. The order and its event commit together; the relay publishes later, at-least-once.
A saga trades isolation for availability. Local transactions plus idempotent compensations, honest states, a deadline per order.
Jobs must be idempotent, and bounded queues keep overload visible.
Practice exercises
Exercise 1 - Starter (30 min): Draw the state machine for an order whose payment is declined at step 2. List the states, the compensation that runs and the notification sent. Mark the point of no return and say why.
Exercise 2 - Intermediate (1 hour): QuickCart now publishes 8 events of 1.5 KB per order, with 14 days of retention and 3 replicas. Compute the peak events per second and the disk needed at 500,000 orders per day.
Exercise 3 - Advanced (half day): Stores can reject an order. Add a "store accepts" step with a 2-minute timeout to the saga. Decide where it goes, which step becomes the pivot, each compensation, and how the jobs table implements the timeout. Update the state diagram.
Lesson 5 of 11 · Backend System Design Project: Build QuickCart
Make QuickCart checkout asynchronous with queues, a Kafka-style event log, an outbox, a saga with compensations, background jobs and backpressure.
In the rest of this lesson
Writing the order and the event together
Coordinating the steps
Running work in the background
Staying alive under load
One checkout, end to end
Decisions and trade-offs
Enrolling is free. It unlocks every lesson in Backend System Design Project: Build QuickCart, tracks your progress across devices and keeps your quiz scores. The newsletter is optional.
Real architecture decisions, LLMOps patterns that survive production, and engineering leadership advice — from 12+ years of building at enterprise scale. Free. No spam. Unsubscribe anytime.
Get new articles by email
Free. We email a confirmation link first, and you can unsubscribe from any email.