A database commit and a message publish are two different systems, and the moment your application code treats them as one operation, it has created a bug waiting for the wrong kind of crash. Update the row, then publish the event: if the process dies between those two calls, the state changed but nobody heard about it. The outbox pattern closes that gap by writing the event into an outbox table in the exact same local transaction as the business update, so both commit together or neither does, then handing publishing off to a separate, retryable process. The fix looks almost too simple for the reliability problem it solves, which is often why teams skip it until a lost event in production forces the question.
What changed in 2026
- CDC-based relays became the default choice. Reading the outbox table through a log-based connector, rather than polling it, is now the common recommendation for anything beyond a small service.
- Framework-level outbox support matured. Several web and service frameworks now ship a built-in outbox table and relay as a documented feature, rather than something every team hand-rolls from scratch.
- Schema registries extended to outbox payloads. Teams increasingly version the event shape stored in the outbox row itself, catching breaking consumer changes before they ship.
- Outbox cleanup got automated. Time- or status-based archiving jobs for published rows are now a standard part of the pattern's reference implementations, closing a gap that used to cause slow, bloated outbox tables.
How it works
The business transaction and the event write happen together, inside one commit:
BEGIN;
UPDATE orders SET status = 'paid' WHERE id = 42;
INSERT INTO outbox (id, aggregate_id, event_type, payload, created_at)
VALUES (gen_random_uuid(), 42, 'OrderPaid', '{"orderId":42}', now());
COMMIT;
If the transaction commits, both the state change and the pending event exist durably. If it rolls back, neither does. A separate relay process then reads unsent outbox rows, publishes each one to a broker such as Kafka, and marks it sent, entirely decoupled from the original request. This is the entire trick: nothing about the outbox insert is special to any messaging system, it is an ordinary row in an ordinary table, which is exactly why it can share a transaction with the business update in the first place.
Two ways to build the relay
| Relay style |
How it works |
Tradeoff |
| Polling publisher |
A background job periodically queries unsent outbox rows and publishes them |
Simple to build, adds polling latency and read load |
| CDC-based relay |
A log-based connector, such as Debezium, streams new outbox rows the moment they commit |
Lower latency, no polling load, but adds a CDC pipeline as a dependency |
A CDC-based relay is the more common choice in 2026 for anything beyond a small service, since it removes the polling delay entirely and scales with the write rate rather than a fixed poll interval. For the mechanics of that connector layer, see change data capture explained.
Common mistakes
- Writing to the broker and the database as two separate calls. This recreates the exact dual-write problem the outbox pattern exists to solve.
- Never cleaning up the outbox table. Published rows need a deletion or archiving policy, or the table grows without bound and slows every write to it.
- Assuming exactly-once delivery. Relays are typically at-least-once; consumers need an idempotency key or a deduplication check.
- Losing event ordering across aggregates. If consumers depend on strict ordering, key the downstream topic by aggregate ID, not by insertion time alone.
- Storing a large payload instead of a reference. A big object embedded directly in the outbox row bloats both the table and the message; many teams instead publish an identifier and let the consumer fetch full details if it needs them.
FAQ
Is the outbox pattern only useful for microservices?
No, though it is most valuable when a database write and an external notification must stay in sync across a service boundary.
Do I need Kafka to use an outbox?
No. Any reliable message transport works, and a CDC pipeline reading the outbox table is common, but a specific broker is not required.
What happens if the relay crashes mid-publish?
It resumes from the last row it confirmed as sent, which is exactly why idempotent consumers matter, since a row can be republished after a crash.
How is this different from running CDC on the main business tables?
CDC on business tables exposes every row change as an implicit event tied to the internal schema. An outbox table lets you publish an explicit, versioned event shape you control directly.
Should the outbox table live in the same database as the business data?
Yes. That is the entire point — it must share a transaction with the business update, which is only possible if both live in the same database and commit together.
Where to go next
See change data capture explained for the connector layer behind most modern relays, distributed transactions in microservices for where the outbox pattern fits in the bigger picture, and sync vs async for the broader tradeoff between doing work inline and handing it off.