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.

Loi LeFounder, TechNova · · 6 min read
Prefer video? The 1-minute version.

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

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:

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

The short answer

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.

Seeing lost or duplicate events in your system?We find and fix this kind of bug in a tech audit, usually in one to two weeks.

Read next: Read replicas: two production traps nobody warns you about