Reference
Software Architecture
What Is the Outbox Pattern?
A canonical explanation of the outbox pattern: how to update a database and publish an event without the two silently drifting apart when one of them fails.

The code looks innocent. Save the order to the database, then publish an order.placed event so the rest of the system can react. Two lines, one after the other. In a demo it works every time.
In production it eventually doesn’t. Between those two lines the process can crash, the broker can be unreachable, the network can blink. And when it does, you are left with one of two silent lies: an order that exists but was never announced, or an announcement for an order that was never saved. Nobody threw an error. The two stores just quietly disagree, and downstream systems act on the wrong version of reality.
The outbox pattern exists to make those two lines behave like one.
The problem: the dual write
This is the dual-write problem, and it has no clean solution as long as you treat the database and the broker as two things to update in sequence. Wrapping both in a try/catch does not help: retrying the publish after a crash requires knowing it failed, and the knowledge died with the process. Distributed transactions across a database and a message broker (two-phase commit) can technically bind them, but they are slow, poorly supported by modern brokers, and a well-known operational trap.
The honest fix is to stop doing two writes. Do one.
How it works
Definition
- Outbox pattern
A technique that makes a state change and its outgoing event atomic by writing the event into an
outboxtable inside the same database transaction as the state change. A separate process then reads unpublished rows from that table and delivers them to the broker, marking each as sent.
The insight is that your database already gives you atomicity — within a single transaction. So instead of publishing to the broker directly, you write the event as a row in an outbox table, in the same transaction that writes the order:
BEGIN; INSERT INTO orders (id, customer_id, total) VALUES (...); INSERT INTO outbox (id, topic, payload, published_at) VALUES (gen_random_uuid(), 'order.placed', '{...}', NULL);COMMIT;Now there is exactly one write, and it either fully commits or fully rolls back. The order and its pending event are saved together or not at all — the dual write is gone.
A second component, the relay (or dispatcher), does the actual publishing. It polls the outbox for rows where published_at IS NULL, sends each to the broker, and marks it published:
Relay loop: rows = SELECT * FROM outbox WHERE published_at IS NULL ORDER BY id for row in rows: broker.publish(row.topic, row.payload) // may retry UPDATE outbox SET published_at = now() WHERE id = row.idIf the relay crashes mid-loop, the unmarked rows are still there on restart; it simply picks up where it left off. A slicker variant reads the database’s replication log instead of polling — change data capture, as Debezium does — but the guarantee is the same: the event is durable the instant the transaction commits, and delivery is a separate, retryable concern.
This is the same lineage as the event log at the heart of event sourcing and the messaging that powers event-driven architecture: the outbox is the bridge that lets a service reliably turn a local commit into a message others can trust.
Trade-offs
The outbox buys reliability, and it charges for it.
- At-least-once, not exactly-once. The relay can crash after publishing but before marking the row sent, so the same event can be delivered twice. Consumers must be idempotent — key reactions on the event’s ID so a replay is harmless. This is not a flaw to fix; it is the contract.
- Latency. A polling relay adds a small delay between commit and publish. Usually milliseconds, but it is not instant.
- Operational surface. You now run and monitor a relay, and the outbox table needs pruning so it does not grow forever.
- Ordering takes care. If consumers depend on event order, the relay must preserve it — typically by publishing per aggregate in ID order.
When not to use it
Reach for the outbox precisely when a lost or phantom event would corrupt downstream state. That is the situation it was designed for, and outside it the extra table and relay are cost without benefit.
Key takeaways
- The outbox solves the dual-write problem: a database commit and a broker publish cannot share a transaction, so a crash between them leaves the two disagreeing.
- It works by writing the event into an outbox table in the same transaction as the state change, making them atomic, then relaying rows to the broker separately.
- Delivery is at-least-once, so consumers must be idempotent; this is the pattern’s contract, not a defect.
- It pairs naturally with event sourcing and event-driven architecture — it is how a service turns a local commit into a message others can rely on.
- Use it only where a lost event is unacceptable; for best-effort signals, publishing directly is fine.
The outbox does not make publishing an event impossible to lose. It makes the loss recoverable — because the event is safely on disk, inside the same commit as the truth it describes, waiting to be sent again.
// continue exploring