DBRaven
Pattern · messaging

Inbox Pattern (Idempotent Consumer)

mature

Summary

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

Duplicate processing prevention
+0.9

Eliminates duplicate side effects under at-least-once delivery, correct across consumer restarts, rebalances, and broker redeliveries, without any coordination with the broker itself

Atomicity of dedup and processing
+0.8

The UNIQUE constraint check and the business logic commit together; no partial state from a crash between the two

Implementation simplicity
+0.7

Needs only a table with a unique constraint, supported by any relational database; no external coordination service

Write latency
-0.1

One additional INSERT per message on the same transaction as the business write

Storage and cleanup overhead
-0.2

The inbox table grows unbounded without a pruning job matched to the broker's redelivery window

Scope of the guarantee
-0.3

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

mandatory

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.

mandatory

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.

mandatory

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.

recommended

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.

recommended

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

Scales on
write
Implementation complexitylow
Operational complexitylow
Scaling ceilingThe inbox INSERT adds one write per message on top of the business logic's own writes, so throughput scales with the database's write capacity, the same ceiling the outbox pattern's relay hits at high volume. An unpruned inbox table degrades its own index over time; the cleanup job's cadence needs to keep pace with message volume, not just run on a fixed schedule.

Technologies

Canonical

postgresqlmysql

Alternatives

redisdynamodb

Relationships

Complements

outbox patterncompeting consumerssaga patternevent sourcing

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

ComplementsPattern
outbox pattern
Grounded

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 →
MitigatesFailure Mode
partial failure
Draft · unverified

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

Benefits FromWorkload
financial transaction workload
Grounded

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 →

Used In Architecture Scenarios