Communication PatternsMessagingReliability

Inbox Pattern

Make message consumption idempotent at the database level by recording processed message IDs in an inbox table within the same transaction as the business side-effect — preventing duplicate processing even when brokers re-deliver messages.

⏱ 10 min read

What it is

The inbox pattern is the consumer-side complement to the transactional outbox pattern. It solves the duplicate message delivery problem: message brokers guarantee at-least-once delivery, which means a consumer can receive the same message more than once (after a broker retry, after a consumer crash that leaves a message unacknowledged, or after the visibility timeout expires). If the consumer is not idempotent, duplicate delivery causes duplicate side-effects — double charges, double emails, double inventory decrements.

The inbox pattern prevents this by inserting the message ID into a dedicated inbox (or processed_messages) table in the same database transaction as the business side-effect. If the same message is received again, the consumer tries to insert the message ID but hits a primary key conflict — the message is detected as a duplicate and skipped without re-applying the side-effect. The uniqueness constraint in the database is the deduplication gate.

Why it exists

Building truly idempotent business logic is harder than it seems. Natural idempotency (e.g., setting a field to a value is idempotent by nature) works for some operations, but others — charging a payment, sending an email, decrementing a counter — are inherently non-idempotent. The inbox pattern provides a universal, mechanical way to make any consumer idempotent without requiring the business logic itself to be idempotent: the deduplication check is a separate concern handled at the infrastructure level. Combined with the transactional outbox pattern on the producer side, it enables exactly-once semantics end-to-end (at-least-once delivery + consumer-side deduplication = effectively exactly-once processing).

When to use

  • Any consumer that processes non-idempotent operations — charging payments, sending notifications, decrementing inventory, publishing downstream events.
  • Consumers of at-least-once brokers — SQS, Kafka (without transactional APIs), RabbitMQ.
  • Saga participants — each step in a saga must be idempotent because the saga orchestrator may retry failed steps.
  • Paired with transactional outbox — to achieve end-to-end exactly-once semantics across the producer and consumer.

When not to use

  • When the consumer is already naturally idempotent — if processing the message twice has no additional effect (e.g., updating a field to the same value), the inbox overhead is not warranted.
  • When the consumer has no relational database — the pattern relies on a transactional INSERT with a unique constraint. Redis SET NX can serve a similar purpose for stateless consumers but does not provide the same transactional guarantee.
  • When Kafka transactional exactly-once is already configured — Kafka's transactional producer + consumer with read_committed isolation provides exactly-once within the Kafka ecosystem; the inbox pattern is an alternative for cases where that approach is not viable.

Typical architecture

The consumer receives a message. Before applying any side-effect, it attempts to insert the message ID into the inbox table within a database transaction. If the INSERT succeeds (no prior record), the consumer applies the business side-effect in the same transaction and commits. If the INSERT fails with a unique key violation, the message is a duplicate and the consumer ACKs it without applying the side-effect.

Consumer receives message:
  { "messageId": "msg-abc-123", "type": "payment.completed", "orderId": "ord-1" }

BEGIN TX
  -- Attempt deduplication check
  INSERT INTO inbox (message_id, processed_at)
  VALUES ('msg-abc-123', NOW())
  ON CONFLICT (message_id) DO NOTHING

  -- Check rows affected
  IF rows_affected == 0 THEN
    -- Duplicate detected, skip
    ROLLBACK
    ACK message to broker (discard safely)
    RETURN

  -- No conflict: first time processing
  UPDATE orders SET status = 'paid' WHERE id = 'ord-1'
  INSERT INTO outbox (...) VALUES (...)  -- optionally publish downstream event
COMMIT TX

ACK message to broker

-- Second delivery of same message:
BEGIN TX
  INSERT INTO inbox (message_id, ...) VALUES ('msg-abc-123', NOW())
  → UNIQUE CONSTRAINT VIOLATION → 0 rows affected
ROLLBACK
ACK (duplicate, safe to discard)

Pros and cons

Pros

  • Universal idempotency — works for any business operation regardless of whether the operation itself is naturally idempotent.
  • Transactional guarantee — the deduplication check and the side-effect are atomic; no race conditions between concurrent consumer instances.
  • Simple to implement — a single table, a unique constraint, and a conditional check in the consumer.
  • Enables exactly-once end-to-end when combined with the transactional outbox pattern on the producer side.

Cons

  • Database dependency — requires the consumer's primary data store to be a transactional database; does not apply to stateless consumers or non-relational stores without transaction support.
  • Inbox table must be pruned — expired entries must be deleted on a schedule; an unbounded inbox table degrades INSERT performance and query performance over time.
  • One extra DB write per message — the inbox INSERT adds a small overhead to every message processed.
  • Deduplication window — entries are only retained for the configured TTL; a re-delivery after the TTL expires would be treated as a new message. Set TTL > maximum message retention period of the broker.

Implementation notes

Inbox table schema: Minimal schema: message_id VARCHAR(255) PK (the unique broker message ID — SQS MessageId, Kafka offset+partition, or a business-level event ID), processed_at TIMESTAMP NOT NULL DEFAULT NOW(), optionally consumer_type VARCHAR if multiple consumer types share the same database. The primary key on message_id is the uniqueness constraint that drives the deduplication check.

Message IDs: Use the broker's native message ID if it is stable and unique (SQS MessageId, Kafka partition + offset string, RabbitMQ delivery tag is not suitable — use messageId property). For events published via the transactional outbox pattern, the outbox row's UUID is a stable, unique event ID that can serve as the inbox message ID. This ensures that even if the outbox relay re-delivers the same event, the inbox catches the duplicate.

Composite inbox key: If the same message ID could legitimately be processed by different consumers in the same database (e.g., multiple consumer types share a schema), use a composite key: (message_id, consumer_type).

Inbox TTL and pruning: The inbox table only needs to retain entries long enough to cover the maximum re-delivery window of the broker. For SQS, the maximum message retention period is 14 days — set the inbox TTL to at least 14 days. For Kafka, it depends on the topic retention configuration. Use a scheduled job to delete rows where processed_at < NOW() - INTERVAL '14 days'. Partition the table by processed_at month for fast partition drops.

Horizontal scaling with competing consumers: Multiple consumer instances can safely use the same inbox table concurrently. If two instances receive the same message simultaneously (race condition), only one INSERT will succeed; the other will get a unique constraint violation and safely skip the message. The database uniqueness constraint provides the serialisation without any consumer-level coordination.

Combining with the transactional outbox (exactly-once end-to-end): The producer writes to the outbox atomically with the business write. The outbox relay publishes at-least-once to the broker. The consumer uses the inbox to deduplicate at the consumer side. The result: even if the outbox relay re-delivers the same event multiple times, the consumer will process the business logic exactly once. This is the closest practical approximation to exactly-once semantics in a distributed system without Kafka transactions.

Common failure modes

  • ACKing the message before the transaction commits: If the consumer ACKs the message to the broker before the DB transaction commits and the DB transaction subsequently fails, the message is lost — neither processed nor redelivered. Always ACK after a successful commit.
  • Inbox table never pruned: Over time the table grows to millions of rows. INSERT performance degrades as the index grows. Implement pruning or range partitioning from day one.
  • Message ID not stable or not unique: Using a non-stable ID (e.g., RabbitMQ delivery tag which changes on re-delivery, or a wall-clock timestamp) breaks the deduplication check. Use a stable, immutable message ID generated at publish time.
  • Deduplication window too short: A message is re-delivered after the inbox TTL; the inbox row has been pruned; the message is treated as new and double-processed. Set the TTL to exceed the broker's maximum retention period.

Decision checklist

  • Does the inbox INSERT and the business side-effect execute in the same database transaction?
  • Is the message ACKed to the broker only after the database transaction successfully commits?
  • Is a stable, immutable message ID used (not the RabbitMQ delivery tag or a timestamp)?
  • Is the inbox TTL set to at least the maximum broker message retention period?
  • Is there a scheduled pruning job (or range partition drop) to prevent unbounded inbox table growth?
  • Is the inbox table tested under concurrent consumer load to verify the uniqueness constraint race condition is handled correctly?

Example use cases

  • Payment charging: Payment consumer receives payment.requested; uses inbox to ensure the charge is attempted exactly once even if the message is re-delivered after a consumer crash mid-charge.
  • Email notification service: Welcome email consumer deduplicates user.registered events; even if the event is re-delivered multiple times, the user receives exactly one welcome email.
  • Inventory decrement: Fulfilment consumer decrements inventory for order.confirmed; inbox prevents double-decrement when the outbox relay re-delivers the event after a relay crash.
  • Transactional Outbox Pattern — the producer-side complement; together they provide end-to-end exactly-once semantics.
  • Competing Consumers — multiple competing consumers safely share the same inbox table; the uniqueness constraint serialises duplicate attempts.
  • Message Queues — the at-least-once broker that makes the inbox pattern necessary.

Further reading