Outbox Pattern
A pattern where a service writes events to an outbox table in the same database transaction as its state change. A separate process reads the outbox and publishes events, ensuring atomicity.
What is Outbox Pattern?
In short
The outbox pattern is a way to publish events reliably from a service by writing the event into an "outbox" table inside the same database transaction that changes the service's state, then having a separate process read that table and push the events to a message broker. Because the state change and the event row commit together or not at all, you never end up with a database update that has no matching event, or an event for something that never actually happened.
The problem it solves
Imagine an order service that saves a new order to Postgres and then publishes an OrderPlaced event to Kafka so the payment and shipping services can react. These are two separate systems: a database and a message broker. There is no single transaction that spans both.
If the service writes to the database and then crashes before publishing, the order exists but nobody downstream ever hears about it. If it publishes first and then the database write fails, you have told the world about an order that does not exist. This is the dual-write problem, and it shows up any time one action has to update local state and notify other services.
Retrying does not fix it cleanly either. A naive retry can publish the same event twice, or publish an event for a transaction that later rolled back. You need the state change and the intent to publish to be decided together, atomically, in one place. The one place where you already have a real transaction is your database.
How it works under the hood
You add a second table, often called outbox or events, to the same database the service already writes to. When the service handles a command, it does the business write and an insert into the outbox table inside one local transaction. The outbox row holds everything needed to publish later: an event id, the topic or aggregate type, a payload as JSON, and a status or created timestamp.
Because both writes share one transaction, the database guarantees they commit together. If the business write rolls back, the outbox row disappears with it. There is now exactly one durable record of intent.
A separate component, the message relay, moves rows from the outbox to the broker. There are two common ways to drive it. Polling publisher: a background worker runs a query like select from outbox where published_at is null order by created_at, publishes each event, then marks it sent. Change data capture: a tool such as Debezium tails the database write-ahead log, sees new outbox inserts, and streams them to Kafka with no extra query load on the database.
Delivery is at-least-once, so the same event can arrive more than once after a crash or retry. The fix is to ship a stable event id with every message and make consumers idempotent, usually by recording processed ids and ignoring duplicates.
When to use it and the trade-offs
Reach for the outbox pattern when a service owns a relational database and must publish events or call other services as part of the same logical operation, and losing or duplicating those events would corrupt downstream state. Order processing, payments, inventory, and any saga step are typical fits.
The cost is extra moving parts. You now run a relay or a CDC pipeline, monitor outbox lag, and clean up old rows so the table does not grow without bound. A polling relay adds query load and a small delay, often hundreds of milliseconds to a few seconds, between the commit and the event landing on the broker. CDC removes the polling load but adds operational weight from running Debezium and Kafka Connect.
It is also not a magic bullet for ordering. The relay preserves the order rows were committed in if you publish by created order and use a single partition key per aggregate, but with multiple workers or partitions you can reorder events, so design consumers to tolerate that.
If your service does not have a transactional database, or you genuinely need exactly-once cross-system writes, the outbox does not apply directly. In that case people reach for two-phase commit, which is slower and brittle, or accept at-least-once with idempotency, which is what the outbox embraces.
A concrete example
A checkout service receives a place order request. In one Postgres transaction it inserts a row into orders and a row into outbox with event_id, aggregate_type order, type OrderPlaced, and a JSON payload of the order details. The transaction commits, the HTTP response returns 200 to the customer.
Moments later, Debezium reads the Postgres write-ahead log, sees the new outbox insert, and publishes an OrderPlaced message to the orders Kafka topic keyed by order id. The payment service consumes it, checks whether it has already processed that event_id, and if not, charges the card.
If the checkout service had crashed right after commit, nothing is lost: the outbox row is durable and the relay picks it up on recovery. If the relay published the event twice after a restart, the payment service sees the same event_id, recognizes the duplicate, and skips the second charge. State stays correct on both sides without any distributed transaction.
Where it is used in production
Debezium
Open source CDC platform widely used to implement the outbox relay by tailing the database log and streaming outbox rows to Kafka.
Shopify
Uses transactional outbox style patterns to reliably emit domain events from order and commerce services without losing or duplicating them.
PostgreSQL
Common home for the outbox table; its logical replication and write-ahead log make both polling and CDC based relays straightforward.
Microsoft Azure
The eShopOnContainers and .NET microservices reference architectures ship an integration event log table that is the outbox pattern by another name.
Frequently asked questions
- What is the difference between the outbox pattern and just publishing to Kafka after the database commit?
- Publishing after commit is the dual write that the outbox fixes. If the service crashes between the commit and the publish, the event is lost forever. The outbox makes the event durable inside the same transaction as the state change, so a separate relay can always retry it after a crash.
- Does the outbox pattern give exactly-once delivery?
- No. It gives at-least-once delivery. The same event can be published more than once after retries or crashes. You make the end result correct by attaching a stable event id to each message and making consumers idempotent so duplicates are ignored.
- Polling versus change data capture, which relay should I use?
- Polling is simpler to build and fine at low to moderate volume, but it adds query load and a bit of latency. CDC with a tool like Debezium scales better and avoids polling the table, at the cost of running Debezium and Kafka Connect. Pick polling to start, move to CDC when volume or latency demands it.
- Does the outbox preserve event ordering?
- It can, if you publish rows in commit order and key all events for one aggregate to the same partition. With multiple relay workers or partitions, events for different aggregates can interleave or reorder, so consumers should not assume strict global ordering.
- Do I need to clean up the outbox table?
- Yes. Once an event is confirmed published you should mark it and periodically delete or archive old rows, otherwise the table grows forever and slows down the relay query. A scheduled job that purges rows older than a retention window is the usual approach.
Learn Outbox Pattern hands-on
This page explains the idea. The full lesson lets you step through the ring as servers join and leave, read the implementation, and check yourself with a quiz. It is one of 760+ lessons in the System Design Masterclass, from your first API call to distributed consensus. Eleven Foundation lessons are free, no signup. Lifetime access is ₹499 in India or $7.99 worldwide, one payment, no subscription.
Related lessons
Lessons that touch on Outbox Pattern as part of a larger topic.
See also
Related glossary terms you might want to look up next.
Event Sourcing
Storing every state change as an immutable event instead of just the current state. You can rebuild any past state by replaying events.
Change Data Capture
Capturing row-level changes in a database and streaming them to other systems in real time. Debezium reads the write-ahead log and publishes changes to Kafka.
Saga Pattern
A way to manage distributed transactions across microservices using a sequence of local transactions with compensating actions for rollback.
CAP Theorem
In a distributed system, you can only guarantee two of three: Consistency, Availability, and Partition tolerance. You must choose your trade-off.
Consensus
The process of getting multiple nodes in a distributed system to agree on a single value. The foundation of distributed databases and coordination services.
Paxos
A family of protocols for solving consensus in unreliable networks. Famously difficult to understand but mathematically proven correct.