Transactional Outbox
also called Outbox Table, Reliable Event Publication, Store-and-Forward Events
Writing an event into a table within the same transaction as the business change, then publishing it separately - so the event exists if and only if the change committed, which no ordering of two independent writes can achieve.
A service that must update its database and publish an event faces a problem with no ordering solution. Commit then publish leaves a window in which a crash produces a committed change with no event, permanently and undetectably. Publish then commit can emit an event for a transaction that subsequently rolls back. A distributed transaction across a database and a broker couples their availability, performs poorly, and is unsupported by most modern brokers.
The transactional outbox removes the second write. The event is inserted into a table in the same database, in the same transaction as the business change, so atomicity is provided by the one component that can guarantee it. A separate process then publishes those rows and marks them sent.
Why it matters
The failure it prevents is silent, permanent and undetectable from inside any single component. Each service observes its own operation succeeding; the downstream system simply never learns that something happened. These divergences accumulate slowly and surface as reconciliation discrepancies months later, at which point the originating events are unrecoverable.
It also removes the broker from the write path's availability. A service using the outbox can accept writes while the message broker is entirely unavailable, because publication is decoupled — which is a substantial and frequently unremarked resilience benefit.
Implementation patterns
- Insert the event in the same transaction as the state change, always. This is the whole mechanism.
- Design the outbox row as a deliberate public event, not a dump of the changed table. The outbox schema is the contract, and this is what keeps internal schema changes from breaking consumers.
- Publish with at-least-once semantics and expect duplicates — a crash between publishing and marking produces one. State the deduplication requirement in the consumer contract rather than assuming it.
- Preserve ordering per key by publishing in insertion order within a partition key.
- Use log-based capture rather than polling where available: a connector tails the replication log and publishes outbox inserts, giving lower latency and no polling load, and catching writes made by migration scripts and admin tools that application-level publishing would miss.
- Clean up published rows, or the table grows without bound and eventually degrades the transactional workload.
- Monitor the unpublished backlog and its age, since a stalled publisher is silent otherwise.
- Include an event identifier and a schema version on every row.
Industry example
The pattern is near-universal in event-driven systems built on relational databases, and it is the standard recommendation wherever the dual-write problem is discussed. Change-data-capture tooling — Debezium being the most widely deployed — supports the outbox explicitly as a routing pattern, precisely because publishing raw table changes exposes internal schemas to consumers and creates a breaking change on every column rename.
The combination that most mature systems converge on is outbox for the contract, log-based capture for the transport: atomicity from the database, low latency from the log, and a stable public contract from the outbox schema.
Failure scenarios
- Inserting the outbox row outside the transaction, which reintroduces the exact problem.
- Publishing raw table changes instead of designed events, coupling every consumer to the internal schema.
- Consumers that do not deduplicate, producing double effects during any publisher restart.
- No cleanup, so the outbox table grows until it affects transactional performance.
- Unmonitored publisher failure, where the backlog grows silently for hours.
- Ordering assumed across keys, which the mechanism does not provide.
- A consumer recording that it processed an event outside the transaction containing the effect, which reopens a crash window at the other end.
- Very large payloads in the outbox, bloating the transactional database — a reference plus a fetch is usually better.
Trade-offs
The outbox adds write amplification to every transaction that publishes an event, and a table that must be maintained, monitored and cleaned. For a low-volume service this is negligible; for a very high-throughput write path it is a measurable cost.
It also introduces latency between the commit and the publication — typically milliseconds with log-based capture, longer with polling — so it is eventually consistent by construction. Systems that need a downstream effect synchronously with the transaction cannot use it, and generally should reconsider that requirement rather than attempt a dual write.
The trade is write amplification and eventual consistency in exchange for the guarantee that the event exists exactly when the change did. For anything where a missing event causes a correctness problem — payments, inventory, fulfilment, provisioning — it is not an optimisation but the minimum correct design.
Interview question
"Our order service writes to Postgres and publishes to Kafka. Occasionally an order exists with no downstream record and nobody can explain it. Tell me what is happening, design the fix, and then tell me what new failure mode your fix introduces and who is responsible for handling it."