Stackbook Logo
messagingestablished · medium operational burden

Transactional Inbox

Also known as: inbox pattern, idempotent receiver table

Intent

Record each incoming message in the local database within the same transaction as its effects, so redelivery is detected and processing is atomic.

Problem

A consumer that updates its database and then acknowledges the broker can crash between the two steps. The message is redelivered and applied twice, or acknowledged first and lost.

Forces

  • •Acknowledgement and database commit are separate systems
  • •Redelivery is normal
  • •Handlers may be slow or call other services
  • •Inbox rows accumulate

Solution

Overview

The inbox is the receiving counterpart of the outbox. Where the outbox makes publishing atomic with a state change, the inbox makes consuming atomic with its effects.

The unique message id is the heart of it: the database becomes the arbiter of whether a message was already handled.

Two variants exist. In the inline variant, the handler does the work inside the same transaction that records the id, which is simplest. In the deferred variant, the receiver only stores the message and a separate processor handles pending rows, which isolates slow handlers from the broker connection and gives you a queryable backlog.

How it works

Within one transaction the handler inserts into inbox, applies business changes and commits. Only then does it acknowledge the broker.

BEGIN;
  INSERT INTO inbox(message_id, received_at) VALUES (:id, now())
    ON CONFLICT DO NOTHING;
  -- if 0 rows inserted: duplicate, skip to ack
  UPDATE stock SET qty = qty - :n WHERE sku = :sku;
COMMIT;
broker.ack(id)

The inbox also helps with diagnostics, because each row records when a message arrived and its outcome. Add a status column and attempt count to support retries and manual inspection. Index the id column, keep rows small, and partition the table by time so old data can be dropped cheaply instead of running slow deletes.

Pitfalls

  • Acknowledging before the commit.
  • Using a consumer-generated id, which differs per delivery.
  • Purging rows sooner than the broker can redeliver.
  • Calling external APIs inside the transaction and holding locks.

Key takeaways

  • Dedup row and effect commit together.
  • Use the producer-assigned message id.
  • Ack only after commit.
  • Purge on a schedule longer than redelivery windows.

✓ When to Use

  • •Consumer owns a relational store and needs dedup
  • •Handlers must be atomic with state changes
  • •Messages arrive at least once from the broker

✗ When Not to Use

  • •Handlers are naturally idempotent
  • •No transactional local store exists
  • •Throughput is so high that the extra write is prohibitive

Pros

  • +Reliable deduplication
  • +Atomic effect and dedup record
  • +Receipt decoupled from processing when desired

Cons

  • −Extra write per message
  • −Table growth needs cleanup
  • −Does not cover side effects outside the database

Cost Profile

Infrastructure

Low - table in existing database

Operational

Medium - purge jobs and monitoring

Cognitive

Medium - transaction boundaries

Failure Modes

  • Inbox rows never purged and table bloats

  • Ack sent before commit loses messages

  • Id chosen by consumer instead of producer breaks dedup

Real-World Examples

Alternatives

    Related Patterns

    • outbox-pattern
    • exactly-once-processing
    • dead-letter-queue

    Competency Domains

    data statereliability ops