Back to the map

Module 14 · Distributed transactions · Lesson 5 of 5

Transactional outbox

The standard fix for the dual-write problem: write the message into an outbox table in the same database transaction as the state change, then relay it to the broker. Polling versus change data capture relays.

DeepShould-knowSenior 12 minVideoPro Updated Nov 2026

The video walkthrough is part of Pro.

See what Pro includes
Video walkthrough · 2:19 · narrated, with captions

Why it matters

Every service that changes its database and tells others about it faces the same gap: commit then publish loses the message on a crash; publish then commit announces something that may roll back. The outbox pattern closes that gap with nothing more exotic than a table and a background relay, and it is the mechanism under reliable sagas, event-driven architectures and change propagation to search indexes and caches. It is small enough to describe in a minute and important enough that interviewers listen for it.

The idea

Instead of publishing to the broker directly, the service inserts the message into an outbox table in the same database transaction as its business change. Either both commit or neither does, because they are one transaction. A separate relay reads new outbox rows, publishes them to the broker, and marks them sent. The business transaction never depends on the broker being available, and no message can exist for a change that did not commit.

Order serviceone transactionordersoutboxrelayBrokerConsumerinboxoutbox row: id, aggregate id, type, payload, created_at, sent_at
One local transaction writes the order and the event. A relay publishes from the outbox and marks rows sent. Consumers deduplicate because the relay is at-least-once.

Two kinds of relay

 Polling publisherChange data capture (log tailing)
HowA worker periodically selects unsent rows (FOR UPDATE SKIP LOCKED), publishes, marks sentA connector (Debezium) reads the database's replication log, sees outbox inserts, publishes them
LatencyPoll interval, typically 100 ms to a few secondsNear real time
Database loadRepeated queries on the outbox tableNegligible; reads the log the database already writes
ComplexitySimple; lives in the serviceAnother component to run and monitor; needs logical replication enabled
OrderingPer worker; use one worker per aggregate shard or order by idLog order, which is commit order
FitsSmall services, getting startedMany services, high volume, low latency

Both relays are at-least-once: a crash after publishing but before marking sent republishes the row. That is acceptable because consumers are made idempotent, and it is far better than the at-most-once of a direct publish that might never happen.

Consumers: the inbox pattern

The receiving service records each processed message id in an inbox table in the same transaction as its own state change. A redelivered message hits a unique constraint and is skipped. This makes the whole chain effectively-once end to end: outbox for the producer side, inbox for the consumer side, at-least-once in between. Where the consumer's effect is naturally idempotent (set status to shipped), the inbox can be skipped.

Ordering

Events for the same aggregate (one order) should arrive in order. Publish with the aggregate id as the partition key so they land on one partition, and have the relay publish rows in id order within an aggregate. Events for different aggregates may interleave freely. If strict global order were needed, there would be one partition and one relay, which is a throughput ceiling to avoid.

Keeping the outbox healthy

Index on (sent_at, id) so the poller's query is cheap. Delete or archive sent rows promptly (a nightly job or a short retention) so the table stays small. Alert on the age of the oldest unsent row: a growing backlog means the relay is down or the broker is unreachable, which is exactly the failure the pattern is designed to survive but not to hide. Keep payloads self-contained (the event carries what consumers need) or thin (ids only) consistently, as the pub/sub lesson discussed.

Variants and relatives

Listen to yourself: the service consumes its own published events to update its read models, keeping even internal projections consistent through the same path. Event sourcing goes further and makes the event log the source of truth, so there is no separate outbox; the Architectural patterns module covers it. Transaction log tailing without an outbox table (publishing raw row changes) works for data replication but leaks the schema; the outbox lets you publish well-shaped domain events instead.

Transactional outbox · Backplane