Filter concepts by levelShowing all levels.

System Design · Transactional Outbox and Inbox

Transactional Outbox and Inbox

The outbox table itself, publishing a row after the fact, and making a redelivered event a safe no-op

  • Outbox tabletransient staging, not a permanent log
  • Relaypolling publisher vs CDC/log-tailing
  • Mark + cleanupflag published on ack, purge on a schedule
  • Inbox tablerecord event ID atomically with its effect

1.What the outbox table itself needs to store, and why it is not a generic event log

The outbox table has a narrower, more specific job than a generic event log — it exists purely to hold events that are waiting to be relayed, not to be a permanent historical record. It needs enough structure for the relay to reliably pick up, publish, and then retire each row: an identifier for the event, enough context to route or serialize it correctly (what kind of event it is, what business entity it relates to), the actual payload, and a way to track whether it has been published yet. Once an event is confirmed published, the outbox row has done its job — unlike an event log, which is often kept indefinitely as a source of truth, an outbox table is a transient staging area that is safe to prune once the relay has finished with a row.

Key features

  • A practical outbox row typically needs: a unique event ID, an aggregate/entity type and ID (what business object this event is about), an event type (what kind of thing happened), a serialized payload, a created timestamp, and a published flag or timestamp
  • The published flag (or a nullable published_at timestamp) is what the relay updates only after confirmed delivery — this is the same "mark on confirmed success, not optimistically" discipline the relay concept in this section already covers
  • Unlike a general-purpose event log meant to be a durable, queryable historical record, an outbox table is a transient staging area — its rows exist only to bridge the moment between "committed locally" and "confirmed published," and have no ongoing purpose after that
  • Because it is transient, an outbox table needs an explicit cleanup/retention policy (delete or archive rows past a certain age once published) — without one, a table meant to be small and fast to scan grows indefinitely and its own performance degrades over time
  • If a durable historical record of every event actually is needed for other reasons (audit, replay, analytics), that is a genuinely different requirement best served by a separate event log or stream (e.g. writing the same events to Kafka after they are published, or reading the outbox's own already-published rows into a separate durable store) — not by keeping outbox rows around indefinitely for a purpose they were never designed for

A typical outbox row shape, and outbox vs a general event log

A typical outbox row shape, and outbox vs a general event log
PropertyOutbox tableGeneral event log
Typical columnsevent_id, aggregate_type, aggregate_id, event_type, payload, created_at, published_atSimilar core fields, often plus richer metadata (actor, correlation id, schema version)
PurposeTransient staging until relayedDurable historical record
Row lifetimeDeleted/archived once published and past a retention windowKept indefinitely, or per a long-term retention policy
Queried forAlmost nothing after publish — only used by the relay itselfAuditing, replay, analytics, debugging historical behavior
Outbox table vs. general event log

Outbox table

  • Transient staging until relayed
  • Deleted/archived once published
  • Queried for almost nothing after publish

General event log

  • Durable historical record
  • Kept indefinitely, or per long-term policy
  • Queried for auditing, replay, analytics
  • Outbox table
    • Transient staging until relayed
    • Deleted/archived once published
    • Queried for almost nothing after publish
  • General event log
    • Durable historical record
    • Kept indefinitely, or per long-term policy
    • Queried for auditing, replay, analytics

Command

sql
CREATE TABLE outbox (
  event_id      UUID PRIMARY KEY,
  aggregate_type TEXT NOT NULL,
  aggregate_id   TEXT NOT NULL,
  event_type     TEXT NOT NULL,
  payload        JSONB NOT NULL,
  created_at     TIMESTAMPTZ NOT NULL DEFAULT now(),
  published_at   TIMESTAMPTZ           -- NULL until relayed
);

An outbox table needs just enough structure to relay events reliably — an ID, entity/event type, payload, and a published flag — and is a transient staging area, not a permanent event log. It needs its own retention/cleanup policy for rows already confirmed published, or a table meant to stay small keeps growing indefinitely for no remaining purpose.

2.The outbox relay: polling vs CDC, marking rows published, cleanup

Writing the outbox row is only half the pattern — something still has to notice that row and actually publish it. That "something" is a separate relay process, built one of two ways: a polling publisher that periodically queries the outbox table for unpublished rows, or a change-data-capture (CDC) process that tails the database's own transaction/replication log and reacts to outbox inserts as they happen. Either way the relay has to mark rows as published once the broker confirms receipt, and old published rows eventually need cleanup so the table does not grow forever.

Key features

  • Polling publisher: a scheduled job periodically runs something like SELECT * FROM outbox_events WHERE published = false, publishes each row, then marks it published — simple to build, but adds latency up to the poll interval and repeated read load on the table
  • CDC/log-tailing: a process (e.g. Debezium) reads the database's own write-ahead/binlog replication stream and reacts to outbox inserts as they commit — lower latency and lower query load, but adds an operational dependency on a log-tailing connector
  • Both approaches must mark or otherwise track which rows were already published, because the relay can crash after publishing but before recording that fact — the pattern is inherently at-least-once, so the downstream consumer still needs its own idempotency (an inbox) regardless of which relay style is used
  • Rows are typically either flagged published = true and later purged, or deleted outright once the broker acknowledges — an outbox table with an ever-growing set of published rows and no cleanup job will eventually degrade write performance on the table
  • Ordering across rows for the same aggregate should be preserved by the relay (e.g. publish in insertion order per aggregate ID) — this matters for consumers that assume events for one entity arrive in the order they happened

Polling publisher vs CDC/log-tailing relay

Polling publisher vs CDC/log-tailing relay
PropertyPolling publisherCDC / log-tailing
How it notices new rowsPeriodic query against the outbox tableReads the DB's own transaction/replication log
Typical latencyUp to the poll interval (seconds, tunable)Near-immediate — reacts as the commit happens
Load on the databaseRepeated polling queries, even when idleReads the log stream, not the table directly
Operational costJust a scheduled job — simplest to runExtra component to deploy and monitor (e.g. Debezium + Kafka Connect)
Polling publisher vs. CDC / log-tailing

Polling publisher

  • Periodic query against the outbox table
  • Latency up to the poll interval
  • Simplest to run — just a scheduled job

CDC / log-tailing

  • Reads the DB's own replication log
  • Near-immediate — reacts as the commit happens
  • Extra component to deploy and monitor
  • Polling publisher
    • Periodic query against the outbox table
    • Latency up to the poll interval
    • Simplest to run — just a scheduled job
  • CDC / log-tailing
    • Reads the DB's own replication log
    • Near-immediate — reacts as the commit happens
    • Extra component to deploy and monitor

Command

sql
-- polling publisher's core query
SELECT id, event_type, payload
  FROM outbox_events
  WHERE published_at IS NULL
  ORDER BY created_at
  LIMIT 100;

-- after a successful publish to the broker:
UPDATE outbox_events
  SET published_at = now()
  WHERE id = $1;

Something still has to publish an outbox row after it's written — a polling job (simple, adds poll-interval latency) or a CDC/log-tailing connector (lower latency, more moving parts). Either way, mark rows published only on confirmed delivery, clean up old published rows on a separate schedule, and treat delivery as at-least-once — which is exactly why the inbox pattern belongs on the consumer side.

3.The inbox pattern: recording processed event IDs

The inbox pattern is a table on the consumer side that records the ID of every event already processed, checked in the SAME local transaction as the resulting state change. A message queue or an outbox relay can redeliver the same event more than once — that is normal, expected at-least-once behavior, not a bug to fix upstream. The inbox table is what turns "this event might arrive twice" into "processing it twice has no visible effect," by making the check-and-act atomic: either both the dedup-table insert and the business-data update happen together, or neither does.

Key features

  • The inbox table stores processed event/message IDs, typically with a unique constraint on the ID column so a duplicate insert fails atomically rather than needing a separate read-then-check step
  • The dedup-table insert and the actual business-data update happen in the SAME local transaction — this is what makes the check itself race-free under concurrent delivery of the same event
  • A duplicate delivery is detected BEFORE the business logic runs (insert into the inbox table first; if it conflicts, skip processing and stop) — this is what makes reprocessing a no-op instead of a partial double-effect
  • Inbox tables need their own cleanup — old rows accumulate forever unless pruned by age or by a retention window matching how long the upstream producer might realistically redeliver
  • The inbox pattern assumes at-least-once delivery is normal and unavoidable — it does not try to prevent redelivery, only to make redelivery harmless

Inbox (consumer-side) vs outbox (producer-side)

Inbox (consumer-side) vs outbox (producer-side)
PropertyOutboxInbox
SideProducer, before publishingConsumer, before/while processing
Problem solvedDB write and message publish must both happen or neitherA redelivered message must not be processed twice
Table holdsEvents waiting to be publishedIDs of events already processed
Atomic withThe business-data write that produced the eventThe business-data write the event's processing causes
Inbox: insert-first makes redelivery a safe no-op
checkinsertedduplicatekey

evt_9f2 arrives

payment_confirmed

INSERT event_id

unique constraint

Credit account

first delivery

ROLLBACK

redelivery — safe no-op

  • evt_9f2 arrives — payment_confirmed
    • leads to INSERT event_id (check)
  • INSERT event_id — unique constraint
    • leads to Credit account (inserted)
    • on error, leads to ROLLBACK (duplicate key)
  • Credit account — first delivery
  • ROLLBACK — redelivery — safe no-op

Command

sql
-- inbox table: one row per processed event ID
CREATE TABLE inbox_processed_events (
  event_id UUID PRIMARY KEY,
  processed_at TIMESTAMPTZ DEFAULT now()
);

-- consumer, on receiving an event:
BEGIN;
  INSERT INTO inbox_processed_events (event_id)
    VALUES ($1);            -- fails if event_id seen before
  -- ... apply the event's actual effect here ...
COMMIT;

The inbox table records processed event IDs in the SAME transaction as the effect they cause — insert the ID first (letting a unique constraint reject duplicates), then act, so a redelivered event is a safe no-op instead of a repeat effect.

Remember

  • The outbox write only gets an event ready to publish — a separate relay (polling or CDC) still has to actually send it.
  • A relay is only ever at-least-once: it can crash after publish() succeeds but before marking the row published.
  • The inbox table is the consumer-side mirror of outbox — record the event ID in the SAME transaction as the effect it causes.
  • Outbox rows need cleanup on their own schedule; deleting immediately after an unconfirmed publish() risks silent data loss instead.

Pro tip

  • Pick CDC/log-tailing only when polling latency is a real, measured problem — it trades a simple scheduled job for an extra operational component.
  • Let a unique constraint on the inbox table's event_id column do the duplicate check — a separate SELECT-then-INSERT reintroduces the exact race the table exists to close.

Next up

  • Sharding and partitioning
  • Replication
  • Consensus and leader election

Sources

  • System Design Primer — github.com/donnemartin/system-design-primer
  • microservices.io — Transactional Outbox pattern
  • Checked 2026-08-22

Spotted a mistake on this page? Tell us.