Transactional outbox and the dual-write problem: an interactive simulator
A service that saves to its database and publishes an event to a message broker is doing two writes to two systems, and no transaction covers both. Whichever it does first, a crash or an outage between them leaves the two disagreeing. The transactional outbox fixes this by writing the event into the database, in the same transaction as the data, and publishing it from there afterwards.
The playground runs a minute of orders through four approaches. Take the broker down for ten seconds, make requests crash between their two steps, and compare what each one gets wrong.
1,200 orders over a minute, with the broker down from 20 s to 30 s. Writing then publishing saves every order but loses 200 events, all of them placed during the outage: those orders exist, and nothing downstream ever hears about them. Publishing first refuses 200 orders while the broker is down. The outbox loses nothing and invents nothing: events wait in the table during the outage and go out when the broker is back, up to 11.0 s late. What it can do is publish twice: 20 duplicates when its relay crashed, so consumers must be idempotent. Log tailing gives the same guarantees with less delay, 161 ms instead of 602 ms, and resent 20 after its own crash.
- Orders saved
- 1,200
- Orders with no event
- 200
- Events with no order
- 0
- Duplicate events
- 0
- Event delay
- 5 ms
- Orders saved
- 1,000 (200 refused)
- Orders with no event
- 0
- Events with no order
- 0
- Duplicate events
- 0
- Event delay
- 0 ms
- Orders saved
- 1,200
- Orders with no event
- 0
- Events with no order
- 0
- Duplicate events
- 20
- Event delay
- 602 ms (up to 11.0 s)
- Orders saved
- 1,200
- Orders with no event
- 0
- Events with no order
- 0
- Duplicate events
- 20
- Event delay
- 161 ms (up to 10.2 s)
The dual-write problem
Saving an order and then publishing OrderPlaced looks safe until the process dies between the two, or the broker is unreachable. The order is committed and the event is gone: the warehouse never ships, the email is never sent. With 5% of requests crashing between steps, 48 of 1,200 orders go silent; with the broker down for ten seconds, all 200 orders in that window do.
Publishing first just moves the problem. Now a crash after the publish leaves an event for an order that was never saved, 48 of them in the same preset, and while the broker is down the service cannot take orders at all: 200 are refused. Retrying the publish in memory narrows the window but cannot close it, because the retries die with the process.
How the transactional outbox works
- Write both rows in one transaction: the order, and an outbox row holding the event. They commit together or not at all.
- Relay: a separate process reads unsent outbox rows in order and publishes them to the broker.
- Mark sent: after the broker acknowledges, it marks the rows sent, or deletes them.
- Retry until it works: if the broker is down, rows simply wait. In the playground’s outage the events pile up for ten seconds and all go out the moment it returns.
The relay can crash after publishing a batch and before marking it sent, and then publishes it again: 20 duplicates in the playground. So the outbox gives at-least-once delivery, and consumers must be idempotent, for example by recording each event id they have processed. Events also arrive a little late, half a second on average with a one-second poll.
Log tailing instead of polling
Change data capture reads committed changes straight from the database’s replication log, such as the PostgreSQL write-ahead log or the MySQL binlog, and publishes them. Debezium is the common tool, often reading the outbox table so that published events have a deliberate shape rather than mirroring table rows. It adds no polling queries and cuts the delay, 150 ms instead of 500 in the playground, but needs a connector to run and monitor, and it also redelivers after a restart, from its last saved log position.
| Write, then publish | Publish, then write | Outbox | Log tailing | |
|---|---|---|---|---|
| Lost events | On crash or broker outage | None | None | None |
| Events for unsaved data | None | On crash | None | None |
| Broker down | Events lost | Requests fail | Events delayed | Events delayed |
| Duplicates | On retry | On retry | After a relay crash | After a connector restart |
| Extra parts | None | None | Outbox table and relay | Connector reading the log |
The outbox in SQL
-- in the request: the order and its event commit together, or neither does
BEGIN;
INSERT INTO orders (id, customer_id, total) VALUES (:id, :customer, :total);
INSERT INTO outbox (id, topic, key, payload)
VALUES (gen_random_uuid(), 'orders', :id, :event_json);
COMMIT;
-- in the relay, every second: publish unsent rows in order, then mark them sent
SELECT id, topic, key, payload FROM outbox
WHERE sent_at IS NULL ORDER BY created_at LIMIT 500
FOR UPDATE SKIP LOCKED; -- several relays can share the work
-- ... publish each to the broker and wait for acks ...
UPDATE outbox SET sent_at = now() WHERE id = ANY(:published_ids);
-- a crash between the publish and this update sends the batch again: at least onceIn a system design interview
Whenever a service writes data and emits an event, say how they stay in step: “The order service writes the order and an OrderPlaced row to an outbox table in one transaction. Debezium tails the outbox and publishes to Kafka, keyed by order id so events for an order stay in order. Delivery is at least once, so the inventory and email consumers deduplicate on event id.” Expect the follow-ups: ordering, how big the outbox grows during a broker outage, and cleaning up sent rows.
Read the guide to change data capture or delivery semantics, see idempotency keys for handling the duplicates, or watch a strong candidate design a payment system.
Questions people ask
What is the transactional outbox pattern?
A way to save data and publish an event reliably: the service writes the data and an outbox row describing the event in the same database transaction, and a separate relay reads unsent outbox rows and publishes them to the message broker, marking them sent once acknowledged. Both commit or neither does, so events are never lost or invented.
What is the dual-write problem?
The problem of writing to two systems, such as a database and a message broker, with no transaction covering both. If the service writes one and crashes, or the other system is down, they disagree: data is saved with no event, or an event is published for data that was never saved. Retrying in memory narrows the gap but cannot close it.
Does the outbox pattern guarantee exactly-once delivery?
No, at-least-once. The relay can publish a batch and crash before recording it as sent, and then publishes it again. Consumers must be idempotent, for example by storing the ids of events they have processed and skipping repeats, which together with the outbox gives effectively-once processing.
What is the difference between the outbox pattern and change data capture?
The outbox relay polls an outbox table with queries. Change data capture reads committed changes from the database’s replication log, such as the PostgreSQL WAL or MySQL binlog, with a tool like Debezium. CDC adds no polling load and has lower latency; it is often pointed at the outbox table so that published events are designed messages rather than raw table rows.
What happens to the outbox when the message broker is down?
Events keep being written to the outbox with their transactions, so the service keeps accepting requests, and the relay publishes the backlog in order when the broker returns. The cost is delay and a growing table during the outage, which should be monitored, along with the age of the oldest unsent row.
How do you keep events in order with an outbox?
Read outbox rows in commit or insertion order, publish them with a key such as the order id so that all events for one entity go to the same partition, and avoid running several relays over the same rows at once, or partition the work between them so each entity is handled by one relay.
Why not just publish the event before writing to the database?
Because the database write can still fail or the process can crash after publishing, leaving consumers acting on an order that does not exist. It also makes the service depend on the broker for every request: when the broker is down, the service has to refuse requests it could otherwise have saved.