The dual-write problem: why your order saves but the event never arrives
If your service writes to a database and then publishes to Kafka, RabbitMQ or SQS, you have a bug waiting to happen. Here is what it is, why the usual fixes don’t work, and the pattern that does.
A bug with no error and no log
A customer places an order. The order is in your database. But the confirmation email never goes out, the warehouse never hears about it, and nothing in your logs says anything went wrong.
I have seen this exact bug in production more than once. It almost always comes from two innocent-looking lines of code:
db.save(order) // 1. write to the database broker.publish(event) // 2. tell everyone else
Two writes, to two different systems. If the process crashes, restarts or loses the network between line 1 and line 2, the order exists but the event is gone. Swap the lines and it is worse: you announce an order that was never saved.
This is the dual-write problem. It is the same with Kafka, RabbitMQ and SQS, because the database and the broker can’t commit together.
Three fixes that don’t work
- Wrap it in a transaction. Your database transaction can’t include the broker.
- Retry the publish. If the app crashed, there is nothing left running to retry.
- Two-phase commit. Slow and fragile, and Kafka, RabbitMQ and SQS don’t take part in a two-phase commit with your database anyway.
The fix: write once, then let an outbox deliver
Think of an office outbox tray. You don’t run to the post office every time you write a letter. You drop it in the tray and a clerk delivers it later. If the clerk is out sick, the letters wait. Nothing is lost.
In code, the tray is a table in the same database. You save the order and the event in one transaction:
BEGIN;
INSERT INTO orders (id, status) VALUES (42, 'paid');
INSERT INTO outbox (event_id, type, payload)
VALUES ('evt-42', 'OrderPlaced', '{"orderId":42}');
COMMIT; -- both rows, or neither
One database, one commit. Either both rows exist or neither does. The dual write is gone.
Who empties the tray? Polling or CDC
A separate relay reads the outbox and publishes to your broker. There are two common ways:
- Polling. A worker reads unsent rows every second or so, publishes them and marks them sent. Simple to build. Adds a little delay and database load.
- Change data capture (CDC). A tool like Debezium reads the database log and streams new outbox rows out. Faster, but one more piece of infrastructure to run.
For most teams I start with polling, and move to CDC when the delay or the load starts to matter.
The trap most tutorials skip
The relay publishes an event, then crashes before it marks the row as sent. On restart it publishes the same event again. The outbox gives you at-least-once delivery, not exactly-once.
So every consumer must be idempotent: store each event_id it has processed and skip the ones it has already seen. An outbox without idempotent consumers just moves the bug from “lost event” to “charged twice”.
When you don’t need it
- One service and one database: a normal transaction is enough.
- Losing the event is harmless, like an analytics ping: fire and forget.
- A missed event costs money or trust (payments, orders, emails): use an outbox.
Don’t write to two systems. Write once, to your database, and let the outbox deliver. Then make every consumer idempotent.
FAQ
Does the outbox pattern work with SQS and RabbitMQ, or only Kafka?
It works with any broker. The pattern lives in your database, not in the broker.
Does the outbox pattern guarantee exactly-once delivery?
No. It guarantees at-least-once delivery, so consumers must handle duplicates.
How do I keep the outbox table from growing forever?
Delete or archive sent rows on a schedule, for example everything sent more than seven days ago.
Read next: Read replicas: two production traps nobody warns you about