Filter concepts by levelShowing all levels.

System Design · Section 37

Transactional Outbox and Inbox

Level
intermediate
Read
15 min
Concepts
3

An outbox table needs just enough structure to reliably stage events for delivery — an ID, entity/event type, payload, and a published flag — and is a transient staging area rather than a permanent event log, needing its own retention policy once rows are confirmed published. Writing an outbox row alongside business data in one local transaction only gets an event ready to publish, though — a separate relay process still has to notice that row and actually send it, built either as a polling publisher or a CDC/log-tailing connector, marking rows published only on confirmed delivery. Because that relay (and message delivery generally) is only ever at-least-once, the consumer side needs its own mirror-image mechanism: an inbox table that records each processed event ID in the same transaction as the effect it causes, turning a redelivered event into a safe no-op instead of a repeated action.

This section

What is true here

  1. An outbox table needs enough structure to stage events reliably (ID, entity/event type, payload, published flag) and is transient, not a permanent log — it needs its own cleanup policy or it grows indefinitely.
  2. A relay process (polling or CDC/log-tailing) is what actually turns an outbox row into a published message — the local transaction alone does not publish anything.
  3. Rows are marked published only on confirmed broker acknowledgment, never optimistically before it — and old published rows need their own cleanup job.
  4. The inbox pattern records a processed event ID in the SAME transaction as the effect it causes, so a redelivered event (normal under at-least-once delivery) has no visible second effect.

What you will be able to do

  • Design an outbox table with the right columns and a real retention/cleanup policy, not a table treated as a permanent log
  • Explain what component actually publishes an outbox row, and choose between a polling relay and a CDC/log-tailing relay for a given latency/ops-cost tradeoff
  • Implement an inbox table that makes a redelivered event a safe no-op, using a unique constraint rather than a check-then-act race
  • Recognize why outbox and inbox are two distinct halves of the same problem — one on the producer side, one on the consumer side

Outbox: the table itself, and how the relay actually publishes

What the outbox table needs to store and why it stays small on purpose, plus the separate relay process that notices a row and sends it — a polling publisher or a CDC/log-tailing connector, with its own rules for marking rows published and cleaning them up.

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

standardintermediate

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.

Think of it as

An outbox table is like an outgoing mail tray on someone's desk, not a filing cabinet of every letter they have ever sent. The tray only needs enough information to get each letter mailed correctly — an address, the contents, maybe a note about which folder it relates to — and once the mail carrier has actually picked it up, there is no reason to keep that letter sitting in the tray any longer. A filing cabinet (an event log), by contrast, is meant to be a permanent archive people can search through indefinitely. Both hold similar-looking pieces of paper, but they exist for genuinely different purposes, and treating the outgoing tray as if it were the permanent archive means it never gets emptied and keeps growing forever.

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
);

What we're doing: Show an outbox table left unbounded because it was treated as a permanent log, and the cleanup fix.

unbounded-outbox.txttext
Outbox table has been running in production for
8 months, at a steady 50,000 events/day, with a
correctly-working relay marking rows published_at
on confirmed delivery -- but nothing ever DELETES
a published row.

Result after 8 months:
  ~12 million rows, 99.9%+ of them already
  published and never needed again, but still
  sitting in the table.

The relay's own query -- "find unpublished rows"
-- now has to scan through, or index around, 12
million mostly-irrelevant rows just to find the
small handful that are actually still pending.
The table that was meant to stay small and fast
degrades in performance purely from never having
a cleanup policy, even though the actual publish
logic was correct the entire time.
8
This is the accumulation — every one of these rows did its job successfully and then was simply never removed.
13
This is the real cost — a table designed to be small and fast for one narrow query now pays a growing tax for holding data it no longer has any use for.

Why this works: This is the concrete consequence of treating an outbox table as if it were a permanent event log — the publish logic itself can be entirely correct while the table still degrades, purely because retention was never designed as part of the table's lifecycle.

Deleting a row immediately after calling publish(), before confirming it actually succeeded

Wrong

sql
-- deletes the row optimistically, before
-- confirming the broker actually received it
publish(event);
DELETE FROM outbox WHERE event_id = :id;

Better

sql
-- only mark (and later clean up) AFTER a
-- confirmed acknowledgment from the broker
ack = publish(event);
if ack.confirmed:
    UPDATE outbox SET published_at = now()
      WHERE event_id = :id;
-- a SEPARATE, later cleanup job deletes rows
-- with published_at older than a retention window

What you see: An event is silently lost — the outbox row was deleted the instant publish() was called, but the broker never actually received it (a network failure right after the call), and with the row already gone, there is no remaining record that the event was ever supposed to be sent.

Why: Deleting immediately after calling publish() conflates "we attempted to send it" with "it was actually confirmed delivered" — the same distinction the relay concept in this section already makes for marking rows published; cleanup needs to be a separate, later step that only touches rows already confirmed and past a safe retention window, never an immediate action tied to the publish attempt itself.

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

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

Remember: 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.

See also: outbox relay implementation · idempotency implementation

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

standardintermediate

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.

Think of it as

Picture a restaurant's order-ticket rail. The kitchen (the local transaction) prints a ticket the instant an order is confirmed — that part is fast and always happens. A runner still has to notice the ticket and carry it to the delivery driver. One restaurant has a runner walk past the rail every thirty seconds and grab anything new (polling). Another restaurant wires a bell that rings automatically the instant a ticket lands on the rail, so the runner reacts immediately without checking (CDC/log-tailing). Either way, the runner clips a "picked up" tag on the ticket once the driver has it (marking published), and the restaurant clears out old clipped tickets at the end of the night (cleanup) so the rail doesn't pile up forever.

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;

What we're doing: Show a polling outbox relay loop, including the crash-safety gap that makes at-least-once delivery unavoidable.

outbox-relay-loop.txttext
loop every 500ms:
  rows = SELECT * FROM outbox_events
         WHERE published_at IS NULL
         ORDER BY created_at
         LIMIT 100

  for row in rows:
    broker.publish(row.event_type, row.payload)
    -- if the process crashes on THIS line, after
    -- publish() succeeded but before the UPDATE below
    -- runs, the row is still published_at = NULL
    UPDATE outbox_events
      SET published_at = now()
      WHERE id = row.id

  -- next loop iteration will re-select and
  -- re-publish any row still showing NULL —
  -- this is why the pattern is at-least-once,
  -- never exactly-once, on the publish side
8
The publish call itself is not transactional with the UPDATE that follows it — a crash between the two is possible and must be assumed.
16
Any row still unmarked gets re-published on the next loop iteration — the relay guarantees at-least-once delivery, not exactly-once.

Why this works: The gap between "broker confirmed" and "row marked published" cannot be closed with a single local transaction the way the original outbox write could — the broker is a separate system, so the relay itself is only ever at-least-once, which is exactly why the inbox pattern on the consumer side is not optional, it is what makes that at-least-once delivery safe to act on.

Deleting outbox rows immediately after calling publish(), before confirming the broker actually accepted the message

Wrong

text
for row in unpublished_rows:
    broker.publish(row.event_type, row.payload)
    db.execute("DELETE FROM outbox_events WHERE id = %s", row.id)
    -- if publish() silently failed or the broker
    -- connection dropped mid-call, the row is now
    -- gone and the event is lost forever

Better

text
for row in unpublished_rows:
    ack = broker.publish(row.event_type, row.payload)
    if ack.confirmed:
        db.execute(
          "UPDATE outbox_events SET published_at = now() WHERE id = %s",
          row.id)
    -- unconfirmed rows stay NULL and are retried
    -- next loop iteration instead of being lost;
    -- a separate cleanup job purges old published
    -- rows on its own schedule, never on the publish path

What you see: An event that a downstream service depended on never arrives, and there is no trace of it anywhere — the outbox row was deleted on the assumption that publish() succeeded, but the broker never actually received or acknowledged it.

Why: publish() can fail after the network call was sent but before an acknowledgment is received — deleting the row on the optimistic assumption that it worked turns a recoverable retry situation into permanent data loss. Marking rows published only on a confirmed broker acknowledgment, and deleting/purging them later in a separate cleanup step, keeps the retry path intact.

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

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)

Remember: 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.

See also: inbox deduplication · idempotency implementation · at most least exactly once

Advertisement

Inbox/deduplication: making redelivery safe

The consumer-side mirror of outbox — recording processed event IDs atomically with their effect, so at-least-once delivery never produces a repeated action.

The inbox pattern: recording processed event IDs

coreintermediate

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.

Think of it as

Think of a delivery desk at an apartment building that keeps a sign-in sheet of package tracking numbers already logged. A courier might ring the buzzer twice for the same package if the first buzz wasn't acknowledged in time — that's expected, not the courier's mistake. The front desk doesn't argue with the courier about it; it just checks the sign-in sheet, and if that tracking number is already logged, it doesn't log the package or notify the resident a second time. The sign-in sheet and the "notify resident" action happen as one motion — never one without the other — so there is never a moment where a package is both "logged" and "not yet actually handled."

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;

What we're doing: Show a payment-confirmation consumer using an inbox table so a redelivered event does not double-credit an account.

inbox-dedup.sqlsql
-- event: {"event_id": "evt_9f2", "type": "payment_confirmed",
--          "account_id": 42, "amount": 500}

BEGIN;
  -- 1. try to record this event as processed FIRST
  INSERT INTO inbox_processed_events (event_id)
    VALUES ('evt_9f2');
  -- if evt_9f2 was already recorded, this INSERT fails
  -- with a unique-constraint violation right here —
  -- the transaction aborts before any balance changes

  -- 2. only reached if step 1 succeeded (first delivery)
  UPDATE accounts SET balance = balance + 500
    WHERE account_id = 42;
COMMIT;

-- Redelivery of the exact same evt_9f2 later:
BEGIN;
  INSERT INTO inbox_processed_events (event_id)
    VALUES ('evt_9f2');   -- fails: duplicate key
  -- transaction rolls back — balance update never runs
ROLLBACK;
6
The event ID is recorded FIRST, before any business effect — this ordering is what makes a duplicate detectable before it can do damage.
13
The balance update only executes if the insert above succeeded — both are in the same transaction, so there is no window where one runs without the other.
19
On redelivery, the insert alone fails and the whole transaction (including the balance update that was never reached) rolls back — the account is credited exactly once.

Why this works: A message being delivered twice is normal at-least-once behavior, not a rare edge case — without an inbox table, a redelivered payment-confirmed event would credit the account a second time, and no isolation level or retry logic on the sender's side can fix that, because the fix has to live on the consumer.

Checking for a duplicate event with a separate SELECT before the INSERT, instead of relying on the constraint itself

Wrong

text
# two-step check-then-act — not atomic
existing = db.query(
  "SELECT 1 FROM inbox_processed_events WHERE event_id = %s",
  event_id)
if not existing:
    db.execute("INSERT INTO inbox_processed_events ...")
    apply_payment(event)

Better

text
# let the unique constraint do the check atomically
try:
    with db.transaction():
        db.execute(
          "INSERT INTO inbox_processed_events (event_id) VALUES (%s)",
          event_id)
        apply_payment(event)
except UniqueViolation:
    pass  # already processed — safe no-op

What you see: Two workers processing the same redelivered event concurrently both run the SELECT before either has inserted, both see "not found," and both proceed to apply the payment — the exact double-credit the inbox table exists to prevent.

Why: A SELECT-then-INSERT has a race window between the two statements; two concurrent consumers (or two threads of the same consumer) can both pass the check before either commits. Relying on the unique constraint itself to reject the second INSERT closes that window, because the database enforces it atomically rather than the application enforcing it across two separate round trips.

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

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

Remember: 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.

See also: outbox relay implementation · idempotency implementation · idempotent consumer design · concurrency control mechanisms

Advertisement