Inbox Pattern (Idempotent Consumer)
matureSummary
Guarantee idempotent message processing in a consumer by recording each message's unique identifier in a local "inbox" table, in the same transaction as the message's side effect, before that side effect runs. A redelivered message finds its ID already recorded and is skipped, so the side effect happens exactly once even though the broker may deliver the message more than once.
Problem
At-least-once message delivery means a consumer will occasionally receive the same message more than once. A consumer that applies effects on every delivery (double-write, double-charge) produces incorrect state. Idempotency cannot be reliably implemented at the broker; it has to be enforced at the consumer.
Description
Message brokers (Kafka, RabbitMQ, SQS) guarantee at-least-once delivery: a message can be redelivered if a consumer crashes after processing but before acknowledging, or if a redelivery timeout fires before the ack arrives. For a consumer with side effects (a database write, a charge, an email), processing the same message twice can corrupt state.
The mechanism: before processing a message, the consumer attempts to insert the message's unique ID into a local inbox table with a UNIQUE constraint on that ID, in the same database transaction as the business logic itself:
BEGIN;
INSERT INTO message_inbox (message_id) VALUES ($id)
ON CONFLICT DO NOTHING RETURNING message_id;
-- if a row came back, this message has not been seen: apply the business logic
-- if no row came back, this message was already processed: skip it
COMMIT;
-- acknowledge to the broker after the transaction commits
The safety here comes specifically from the UNIQUE constraint's atomicity, not from any check-then-act logic in application code. If two deliveries of the same message race each other, on a rebalance or a redelivery landing concurrently with the original still in flight, both attempt the INSERT, but the database guarantees only one commits the row; the other's ON CONFLICT DO NOTHING clause returns no row, and that delivery skips the business logic. A check-then-insert done as two separate steps would have a race window between them; combining the check and the write into one constrained INSERT removes that window entirely.
The guarantee this delivers is precisely scoped: it makes the business logic run exactly once for effects captured within the same local transaction as the inbox insert. It does not extend to a side effect performed outside that transaction, a direct external API call (a card charge, an email send) made from the handler after the transaction commits, since a crash between commit and that external call, or a retry of the external call itself, is not covered by the inbox's atomicity. Effects that must be exactly-once end to end need to either happen inside the same local transaction (write an outbox row recording the intent, and let a separate relay perform the external call, itself idempotent) or carry their own idempotency key that the external system honors.
The inbox table accumulates rows and needs pruning. A cleanup job removes entries older than the broker's maximum redelivery window (commonly 7 days for Kafka, configurable); an ID that cannot possibly be redelivered after that window is safe to delete.
Inbox is the consumer-side half of a producer/consumer pair with the outbox pattern, and the two are not interchangeable. Outbox solves the producer's problem: reliably getting a message published at least once without a dual-write gap between the database and the broker. Inbox solves the consumer's problem: making redelivery safe once the message arrives. Combining both gives effectively-once processing end to end, the same term outbox_pattern uses for its own guarantee, since the delivery itself remains at-least-once (broker-guaranteed) and only the local processing step is exactly-once; claiming a stronger, unqualified "exactly-once" for the combination would overclaim past what either piece actually provides.
Tradeoffs
Eliminates duplicate side effects under at-least-once delivery, correct across consumer restarts, rebalances, and broker redeliveries, without any coordination with the broker itself
The UNIQUE constraint check and the business logic commit together; no partial state from a crash between the two
Needs only a table with a unique constraint, supported by any relational database; no external coordination service
One additional INSERT per message on the same transaction as the business write
The inbox table grows unbounded without a pruning job matched to the broker's redelivery window
Only covers effects inside the same local transaction as the inbox insert; an external API call made after commit is not covered and needs its own idempotency key
When to use
Consumer performs non-idempotent side effects (database mutation, external API call)
Idempotent operations (read, cache set, upsert by primary key) do not require deduplication
Message broker provides at-least-once delivery semantics
Exactly-once brokers (rare in practice) make the inbox pattern unnecessary
Consumer has access to a transactional database for atomic inbox and business logic
The inbox only works when the deduplication record and the side effect are in the same transaction
Messages carry a stable unique identifier (message ID, event ID, idempotency key)
Without a stable ID, deduplication has nothing to key on
When not to use
Side effects are inherently idempotent (upserts, cache invalidation, read-only queries)
Idempotent operations do not require deduplication; adding inbox overhead is waste
The consumer processes a Kafka topic with exactly-once semantics fully configured
Kafka EOS with transactional producers and consumers prevents duplicates at the broker level
Operational Requirements
Monitor inbox table row count and alert on unbounded growth
Unbounded growth is the direct signal that the cleanup job has stopped running or fallen behind message volume.
Prune inbox rows older than the broker's maximum redelivery window on a schedule
An ID that cannot possibly be redelivered after that window is safe to delete; run hourly or daily depending on volume.
Guarantee message IDs are stable across producer retries
A content hash as the ID is risky if the content can legitimately change between a send and a retry of that same logical message; prefer a producer-assigned UUID set once.
Partition the inbox table by time if message volume is high
The table is append-heavy; time-based partitioning makes bulk pruning of old partitions cheap instead of a row-by-row DELETE.
Pair with the outbox pattern when the producer side also needs reliability, and treat the combination as effectively-once, not exactly-once
Outbox and inbox solve different halves of the problem (producer reliability, consumer idempotency); neither alone, nor the pair, eliminates at-least-once delivery itself.
Characteristics
Technologies
Canonical
Alternatives
Relationships
Complements
Basis
The idempotent consumer pattern is well-established in distributed systems literature and documented in Microservices Patterns (Chris Richardson), Kafka documentation, and enterprise integration patterns; the UNIQUE-constraint-atomicity mechanism and its transactional-scope limit are direct consequences of standard relational database guarantees.
Related Architecture Knowledge
Outbound: this entity affects
The outbox pattern guarantees at-least-once delivery from producer to broker; the inbox pattern guarantees idempotent processing at the consumer. Together they provide end-to-end exactly-once processing.
Full relationship →The inbox pattern ensures idempotent message processing by deduplicating based on message ID, so partial failures that cause redelivery do not result in duplicate side effects.
Full relationship →Inbound: affects this entity
Financial transaction workloads benefit from the inbox pattern to ensure exactly-once payment processing even when the message broker delivers events more than once.
Full relationship →