Skip to content

    System Design

    QuickCart Stage 5: The Order Pipeline With Queues, Events and Sagas

    Make QuickCart checkout asynchronous with queues, a Kafka-style event log, an outbox, a saga with compensations, background jobs and backpressure.

    By Published 24 min read

    Share

    This lesson is part of Backend System Design Project: Build QuickCart. Enroll for free, then sign in, to read it in full.

    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

    Command queue with competing workers and an event topic with fan-outOrder orchestrator to reserve-stock queue (command); reserve-stock queue to Stock workers; Outbox relay to order-events topic (publish); order-events topic to Orchestrator group; order-events topic to Notifications group; order-events topic to Analytics groupCOMMANDS: ONE WORKER PER MESSAGEEVENTS: EVERY GROUP GETS A COPYOrder orchestratorOrderorchestratorreserve-stock queuereserve-stockqueueStock workers: one takes eachStock workersone takes eachOutbox relayOutbox relayorder-events topicorder-eventstopicOrchestrator group: gets every eventOrchestratorgroupgets every eventNotifications group: gets every eventNotificationsgroupgets every eventAnalytics group: gets every eventAnalyticsgroupgets every eventcommandpublish

    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

    Partitions of order-events assigned to two consumers in one groupOutbox relay to Partition 0 (key hash); Outbox relay to Partition 1; Outbox relay to Partition 2; Partition 0 to Orchestrator 1; Partition 1 to Orchestrator 1; Partition 2 to Orchestrator 2TOPIC ORDER-EVENTS (3 OF 12 PARTITIONS SHOWN)ORCHESTRATOR GROUPOutbox relay: key = order idOutbox relaykey = order idPartition 0: ordered, own offsetsPartition 0ordered, ownoffsetsPartition 1: ordered, own offsetsPartition 1ordered, ownoffsetsPartition 2: ordered, own offsetsPartition 2ordered, ownoffsetsOrchestrator 1: partitions 0 and 1Orchestrator 1partitions 0 and1Orchestrator 2: partition 2Orchestrator 2partition 2key hash

    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 NULL DEFAULT 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 = $2 AND 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:

    1. Classify the error in the handler. Permanent errors go straight to the dead-letter queue; timeouts and 5xx responses are retried.
    2. 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.
    3. 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.
    4. 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 NULL UNIQUE,
      aggregate_id text        NOT NULL,
      event_type   text        NOT NULL,
      payload      jsonb       NOT NULL,
      created_at   timestamptz NOT NULL DEFAULT now(),
      published_at timestamptz
    );
    CREATE INDEX outbox_unpublished ON outbox (seq) WHERE published_at IS NULL;
    
    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 NULL UNIQUE,
      payload      jsonb       NOT NULL,
      run_at       timestamptz NOT NULL,
      status       text        NOT NULL DEFAULT 'queued',
      attempts     int         NOT NULL DEFAULT 0,
      locked_until timestamptz
    );
    
    -- Claim up to 20 due jobs; a crashed worker's lease expires after 60 seconds
    UPDATE 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()))
      ORDER BY run_at
      LIMIT 20
      FOR UPDATE SKIP 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.

    1. The API writes the order (PENDING) and the outbox row in one transaction and returns 202.
    2. The relay publishes OrderPlaced (key o-7001); the orchestrator reads it and records its inbox row.
    3. ReserveStock then AuthorisePayment go through queues; each worker replies with an event.
    4. AssignCourier fails after retries within the assignment window (illustratively 2 minutes), so the order moves to COMPENSATING.
    5. VoidAuthorisation and ReleaseStock are retried until they succeed; the order becomes CANCELLED and OrderCancelled is published.
    6. 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

    Starting architecture: checkout calls five dependencies in sequenceShoppers to App servers (checkout); App servers to 1 Stock tables; App servers to 2 Payment gateway; App servers to 3 Courier assignment; App servers to 4 Push and SMS; App servers to 5 Orders shardsShoppersShoppersApp servers: checkout in processApp serverscheckout inprocess1 Stock tables: relational store1 Stock tablesrelational store2 Payment gateway: external2 Paymentgatewayexternal3 Courier assignment3 Courierassignment4 Push and SMS: external providers4 Push and SMSexternalproviders5 Orders shards: by shopper id5 Ordersshardsby shopper idcheckout

    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

    QuickCart after stage 5: outbox, event log, orchestrator, queues and workersShoppers to Checkout API (checkout); Checkout API to Orders shards (write); Orders shards to Outbox relay (read outbox); Outbox relay to Kafka (publish); Kafka to Order orchestrator (events); Order orchestrator to Task queues (commands); Task queues to Step workers; Job scheduler to Order orchestrator (CancelOrder)STAGE 5ShoppersShoppersCheckout API: 202 AcceptedCheckout API202 AcceptedOrders shards: with outbox tablesOrders shardswith outboxtablesOutbox relay: one per shardOutbox relayone per shardKafka: order-events topicKafkaorder-eventstopicOrder orchestrator: saga stateOrderorchestratorsaga stateTask queues: commands, with DLQsTask queuescommands, withDLQsStep workers: stock, payment, courierStep workersstock, payment,courierJob scheduler: delayed and cronJob schedulerdelayed and croncheckoutwriteread outboxpublisheventscommandsCancelOrder

    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.

    Official documentation

    Enroll to keep reading

    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.

    Ask about this article

    Get answers grounded in this post. AI-generated — based on this article, and may be imperfect.

    Scaled AI Weekly

    Enjoyed this? Get more like it every Monday.

    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.

    Join engineers building production AI systems

    Comments