skip to content

A saga step's message handler receives the same 'PaymentCharged' event twice because the broker redelivered it after a timeout. What is the inbox pattern / idempotent consumer approach, and how does it prevent the handler from applying the event's effect twice?

level: seniorimportance: should knowfreq 55%

answer

  1. at-least-once delivery is unavoidable, not a bug
  2. inbox table records processed message IDs
  3. check-and-record happens in ONE local transaction with the business update
  4. mirrors outbox on the consumer side
  5. outbox+inbox = effectively-exactly-once, not truly exactly-once

basics

~20 s

The consumer keeps a record of which message IDs it already processed, in the same database transaction as the work it does. If the same message shows up again, it checks that record first and skips redoing the work, so a duplicate delivery has no extra effect.

solid answer

~50 s

Because saga messaging relies on at-least-once delivery (retries, broker redelivery on ack timeout, outbox relay retries), a consumer must assume it can receive the same event more than once and be idempotent regardless of how deduplication is attempted upstream. The inbox pattern implements this by recording each incoming message's unique ID in an 'inbox' table, in the same local transaction as the business-data change that message triggers — before processing, the handler checks whether that message ID is already in the inbox; if so, it's a duplicate and is skipped rather than reapplied. Because the inbox insert and the business update commit atomically together, there's no window where a crash could leave the message marked-processed without its effect applied, or vice versa. This mirrors the outbox pattern on the producer side, and together outbox-plus-inbox gives you effectively-exactly-once processing built out of two at-least-once legs plus idempotency checks, without ever needing a distributed transaction.

go deeper

for a junior

Should grasp that the same message can arrive twice and that 'doing the same thing twice by accident' is the problem being solved, even without naming the pattern.

for a middle

Should describe checking a record of already-processed message IDs before acting, and know this pairs with the outbox pattern on the sending side.

for a senior

Should insist the inbox check and the business update happen in the same local transaction, and explain why broker-level dedup alone isn't sufficient.

for a principal

Should discuss the precision of 'effectively-exactly-once' versus true exactly-once, inbox table retention/housekeeping, and the requirement for a stable event ID contract between producer and consumer.

## Why delivery is at-least-once Saga steps communicate over messaging infrastructure — brokers, queues, or the outbox relay described elsewhere — that virtually always provides at-least-once delivery rather than exactly-once. This isn't a design flaw; exactly-once delivery across a network is not achievable in general (you can't distinguish "the ack was lost" from "the message was lost" without extra bookkeeping), so brokers and relays choose to occasionally redeliver rather than risk ever silently dropping a message. Consequences: - a consumer's ack can be lost after it already processed a message, causing the broker to redeliver it; - an outbox relay can crash after publishing but before marking a row sent, republishing it on restart; - or a consumer's own processing can succeed but crash before it acknowledges, again causing redelivery. Any of these means a saga participant's handler for, say, `PaymentCharged`, must be prepared to receive that exact event more than once. ## What redelivery costs you Without protection, redelivery causes real damage: an Inventory service reacting to `PaymentCharged` by decrementing stock would decrement twice for one actual payment, or a Notification service would send the customer two "your payment succeeded" emails. ## How the inbox pattern works The **inbox pattern** (also called the idempotent consumer or idempotent receiver pattern) solves this the mirror-image way the outbox pattern solves the producer side. The consumer maintains an inbox table that records the unique ID of every message it has successfully processed (the ID typically comes from the producer — an event ID assigned when the outbox row was created, so it's stable across redeliveries of "the same" logical event). When a message arrives, the handler, within one local database transaction: 1. first checks whether that message's ID already exists in the inbox table; 2. if it does, treats this as a duplicate: it skips reapplying the business effect (and optionally returns whatever result was recorded from the first processing, for handlers that need to reply); 3. if the ID is not present, performs the business-data update and inserts the ID into the inbox table, all inside the same transaction. So — exactly like the outbox pattern — the business effect and the "I've now processed this" bookkeeping either both commit or neither does. There's no gap where a crash between "apply the effect" and "record that I applied it" can cause a subsequent redelivery to be misclassified. ## The main cost The main cost is the extra table and the requirement that the message carry a stable, unique identifier the consumer can dedupe against — if a producer assigns a new random ID to a retried event where a stable ID could have been reused, deduplication silently breaks, so the outbox and inbox conventions need to agree on what counts as the same logical event. The inbox table also needs the same housekeeping as an outbox table — old entries eventually need to be purged, usually after a retention window comfortably longer than the maximum plausible redelivery delay, since keeping every processed message ID forever is unbounded growth. ## What outbox-plus-inbox actually buys you It's worth being precise about what this buys you: outbox-plus-inbox does not give you true exactly-once semantics in the abstract distributed-systems sense — it gives you **effectively-exactly-once** processing, built from two independently at-least-once legs (reliable publish via outbox, reliable dedupe via inbox) plus an idempotency check at the boundary. This is a well-established and sufficient pattern for the vast majority of saga use cases, and it's notably cheaper and simpler than trying to achieve true exactly-once delivery, which generally isn't achievable across independent systems without this exact kind of application-level bookkeeping anyway. ## A concrete failure mode A concrete failure mode without the inbox pattern: a Payment service publishes `PaymentCharged` via its outbox; the relay publishes it to Kafka but crashes before marking the outbox row sent, so on restart it republishes the same event. If the Inventory service's consumer isn't idempotent, it decrements stock a second time for a single real payment, silently under-counting available inventory — a class of bug that's notoriously hard to reproduce because it only manifests on the (rare but inevitable) relay-crash timing window, and shows up in production as slow inventory drift rather than an obvious crash.

  • Why can't you just rely on the message broker's own deduplication features instead of building an inbox table?
    Broker-level deduplication (where offered) typically only guards against duplicates within a limited time window or a single producer session, and doesn't cover redeliveries caused by consumer-side ack failures or outbox-relay retries days apart; an application-level inbox check tied to the actual business transaction is the only place that can guarantee atomicity between 'processed' and 'recorded as processed.'
  • What happens if the inbox check and the business update are done as two separate transactions instead of one?
    The same gap the outbox pattern was built to close reappears on the consumer side: a crash between the two transactions can leave the message marked as processed without its effect applied, or the effect applied without the mark, both of which reintroduce exactly the duplicate-or-lost-effect problem the pattern exists to prevent.
  • Does the inbox pattern require every message to carry a globally unique ID?
    It requires a unique ID that stays stable across redeliveries of the logically same event — usually the ID assigned once when the source outbox row was created — so that redelivery, retry, or relay-crash-and-republish scenarios all present the identical ID the consumer has already seen, rather than a fresh ID each time that would defeat deduplication.

It's like a bouncer checking IDs against a guest list before letting someone into a one-per-person event: even if the same person's invitation gets mailed to them twice, the door staff cross the name off the list the first time they're let in, so showing the duplicate invite a second time gets them turned away rather than double-counted.

saying these in an interview costs you the question

  • Assumes the broker guarantees exactly-once delivery so the consumer doesn't need to worry about duplicates
  • Checks the inbox table and applies the business update in two separate transactions
  • Doesn't know where the deduplication ID comes from or assumes it's arbitrary
  • Confuses the inbox pattern with a dead-letter queue
  • Thinks idempotency only matters for the outbox/producer side, not the consumer

context