Engineering Atlas › System Design Fundamentals

Reliable events with the outbox pattern

12 min read · last reviewed 2026-10-17

Open in the interactive app: practice, quiz, mock interview →

In one minute

How it works

The dual-write problem

Consider INSERT order; COMMIT; publish OrderPlaced. If the process crashes after the commit but before the publish, downstream services never learn about the order. Reverse the order and a crash after publishing but before commit announces an order that does not exist. Wrapping both in a distributed transaction (2PC with the broker) is possible in theory but rarely supported well and couples availability. The outbox avoids the problem by making the event part of the data the database already commits atomically (microservices.io).

Writing to the outbox

In the same transaction as the business change, insert a row into outbox with an id, the aggregate type and id (for example order, 8812), an event type, and the payload. The commit makes both durable together. The service's request path does not talk to the broker at all, so a broker outage no longer fails user requests; events just queue up in the table.

Relaying: polling or log tailing

A polling publisher queries unpublished rows in order, publishes them, then marks them published or deletes them. It is simple and works with any database, but adds query load and latency equal to the poll interval. Transaction log tailing uses change data capture: a connector such as Debezium reads inserts to the outbox table from the database's replication log and publishes them. Latency is lower and there is no polling load, but you now run a CDC pipeline.

Two variants are worth knowing. In a NoSQL store without multi-item transactions, the outbox becomes a field on the record itself, which a change stream then publishes (microservices.io). In PostgreSQL you can skip the table entirely: pg_logical_emit_message(true, prefix, content) writes a message into the WAL as part of the current transaction, so it appears in the logical decoding stream only if the transaction commits, and nothing needs deleting afterwards (Postgres docs).

Ordering and duplicates

The relay can crash after publishing and before recording that it did, so on restart it publishes again: delivery is at-least-once. Consumers keep a record of processed event ids (an "inbox" table) and skip repeats, or make their updates naturally idempotent. To preserve order per entity, publish with the aggregate id as the partition key so all events for one order land on one partition, and never let the relay publish a later event for an aggregate before an earlier failed one.

Housekeeping

The outbox grows with every write. Delete published rows promptly, or partition the table by time and drop old partitions, to avoid bloat that slows both the relay and the business tables' vacuum.

Down the stack

Layer 1The contract

The outbox pattern promises: an event exists if and only if its business transaction committed; every committed event is published at least once, eventually, as long as the relay and broker eventually run; and events for the same aggregate are published in commit order, provided the relay preserves order and partitions by aggregate. It does not promise exactly-once delivery or a latency bound; latency is whatever the relay achieves.

Layer 2The mechanism

A common schema, and the default the Debezium outbox event router expects, has columns id, aggregatetype, aggregateid, type, and payload. The router reads each insert from the change stream and sends it to the topic outbox.event.<aggregatetype> by default, using aggregateid as the message key, so all events for one aggregate share a Kafka partition. It drops DELETE events on the outbox table, so the relay's cleanup produces no extra messages, and it exposes the row id so consumers can deduplicate (Debezium docs). A polling relay typically runs SELECT ... FROM outbox WHERE published_at IS NULL ORDER BY id LIMIT 100 FOR UPDATE SKIP LOCKED, so several relay instances can work without taking the same rows. One subtle trap: sequence values are allocated when a row is inserted, not when its transaction commits, so a row with id 101 can commit after the relay has already processed id 102. A relay that keeps a "highest id seen" cursor will skip 101 forever; selecting by an unpublished flag, or tailing the log in commit order, avoids this. On the broker side, enable.idempotence=true and acks=all stop producer retries from adding duplicates of their own. Both have been the Java producer's defaults since Kafka 3.0, so older advice to switch them on is now mostly about checking nobody switched them off (KIP-679). They do not help with the relay's own crash-and-resend duplicates, which carry a new producer session.

Layer 3The machine

The outbox insert rides on the business transaction's commit: one extra heap tuple, one extra index entry, and perhaps 200 to 500 bytes more WAL, all flushed by the same fsync. That is why the pattern adds almost no latency to the request. Deleting or updating published rows creates dead tuples that vacuum must reclaim; at 5,000 events per second that is 430 million dead tuples per day. Log tailing uses a logical replication slot, which makes PostgreSQL retain WAL until the connector confirms it, even when nobody is connected (Postgres docs); if the connector is down for a day at 20 MB per second of WAL, the primary holds about 1.7 TB of extra WAL and can fill its disk. The cap, max_slot_wal_keep_size, defaults to -1, meaning unlimited (Postgres docs). A polling relay at a 100 ms interval adds an average of 50 ms of delay plus query time; CDC pipelines usually deliver in tens of milliseconds.

Why it's built this way

  1. A database and a broker cannot commit atomically together without 2PC, so events are stored where the business data is.
  2. The database already guarantees atomic commits, so putting the event in the same transaction ties its fate to the change.
  3. Something must move events to the broker, and it can crash between sending and recording, so delivery is at-least-once.
  4. At-least-once delivery creates duplicates, so consumers deduplicate by event id.
  5. The outbox grows with every write and retained WAL fills disks, so cleanup and connector lag must be monitored.

Diagram

sequenceDiagram
    participant API as Order service
    participant DB as Database
    participant R as Relay (poller or CDC)
    participant K as Broker
    participant C as Consumer
    API->>DB: BEGIN, INSERT order, INSERT outbox row, COMMIT
    R->>DB: read unpublished outbox rows
    R->>K: publish OrderPlaced (key = order id)
    K-->>R: ack
    R->>DB: mark row published
    K->>C: deliver OrderPlaced
    C->>C: skip if event id already processed

Worked example

An order service handles 2,000 orders per second, each producing one OrderPlaced event of about 1 KB. Previously it committed and then published directly to Kafka. During a 4-minute broker incident, the publish calls failed after commit, and about 480,000 orders never reached the fulfilment service. Reconciling them took two days.

With the outbox: each order transaction inserts one more 1 KB row, adding under 0.2 ms to commit time. A relay with two instances polls every 50 ms, fetches up to 500 rows using SKIP LOCKED, publishes them in a batch, and deletes them. Normal end-to-end delay is under 100 ms. During the same broker incident, rows accumulate: 2,000 per second for 240 seconds is 480,000 rows, about 500 MB. When the broker recovers, the relay drains them at 20,000 per second in about 25 seconds. No order is lost.

The fulfilment service receives a few hundred duplicates after a relay restart; its inbox table (event id primary key, checked in the same transaction as its own update) drops them. An alert fires when the oldest unpublished row is more than 30 seconds old.

In the wild

Trade-offs

OptionStrengthsWeaknessesChoose when
Direct dual writeSimplest, lowest latencyLoses or invents events on failureNever for events others depend on
Outbox with polling relayWorks with any database, easy to understandPoll load, latency equal to intervalModerate volume, no CDC infrastructure
Outbox with log tailing (CDC)Low latency, no polling loadCDC pipeline to run, WAL retention riskHigh volume, CDC already in place
Event sourcingEvents are the source of truthBig change in data modelDomain benefits from full history
2PC with brokerAtomic in theoryPoor support, blocking, couplingAlmost never in modern stacks

Pitfalls in production

Leadership lens

Check yourself

  1. Why does publishing after commit not solve the dual-write problem?

    Show answer

    A crash between the commit and the publish leaves a committed change with no event, and nothing remembers to send it.

  2. Why do outbox consumers need to be idempotent?

    Show answer

    The relay may publish an event and crash before recording that it did, so the event is published again on restart.

  3. Why can a relay that reads rows with id > last_seen_id lose events?

    Show answer

    Ids are assigned at insert time but transactions commit in a different order, so a lower id can become visible after the relay has moved past it.

Sources and further study

Primary

Systems docs

Books

Other curricula

Watch

Free resources

Open in the interactive app: practice, quiz, mock interview →