Skip to main content

Outbox + CDC: Reliable Events from Your Database

One Write, Not Two

This whole section started with a broken promise. In The Distributed Transaction Problem you saw the dual write: to both change your data and tell the outside world, you update your database and publish an event to a queue — two independent systems, with no shared transaction. A crash in the gap between them and you get an order with no event (the warehouse never ships) or an event with no order (a phantom). And Saga: The Data-Consistency Mechanics made it worse: choreographed sagas depend on those events arriving — a dropped "flight reserved" silently strands the whole workflow.

So how do you emit an event from a database reliably — never losing it, no matter when the process dies? The answer is almost annoyingly simple, and Chris Richardson (microservices.io) states it as a rule, not an optimization:

Write the business state and the event in the same database transaction, then publish it asynchronously from a separate component.

That's the whole idea. Stop writing to two systems. Instead of "commit the order, then publish the event" — two writes that can tear apart — you make the event part of the same commit as the data, by writing it into an ordinary table. Then a separate process ships it onward. One atomic write replaces the racing pair; the tear-in-the-middle is gone because there is no middle. The rest of this lesson is what that table is (the outbox), who ships the events (the relay — by polling, or by tailing the log with CDC), and the one catch you can't design away (at-least-once delivery). It all fits in a single picture:

The outbox pattern as one anatomy. At the top, small and crossed out in red, the naive dual write: the app writes the order to the database and COMMITs, then makes a SEPARATE publish to the queue, with a crash gap between the two where the event is lost. Below it, the fix, drawn large: the app runs ONE local transaction whose box encloses two inserts — INSERT the order row into the orders table AND INSERT the event row into the outbox table — closed by a single COMMIT, labelled 'data + event commit atomically: both, or neither'. From the committed outbox, a separate RELAY reads the new row and publishes it to the queue; the queue feeds a CONSUMER that deduplicates by event id before acting. The bottom line: one atomic write plus a reliable relay replaces two racing writes to two systems.

The Outbox: an Event Is Just Another Row

Add one table. Call it outbox. It holds the events you intend to publish — each row is one event, with a unique event id, a payload, and a published flag:

CREATE TABLE outbox (
  id         bigserial PRIMARY KEY,
  event_id   uuid   DEFAULT gen_random_uuid(),   -- the dedup key (matters in §4)
  aggregate  text,
  payload    jsonb,
  published  boolean DEFAULT false
);

Now the move that makes it all work. In the one local transaction that changes your business rows, you also insert the event into the outbox — two inserts, one commit:

BEGIN;
  INSERT INTO orders  VALUES (1, 'laptop', 1200);
  INSERT INTO outbox (payload)
       VALUES ('{"type":"OrderPlaced","orderId":1,"amount":1200}');
COMMIT;

Because both inserts live in the same transaction, they are atomic together — the guarantee from ACID, Precisely. Either the order and its event both commit, or neither does. There is no longer any window where the data exists without the event. On real Postgres, the outcome is exactly that both-or-neither:

after COMMIT ................  orders = 1   outbox = 1
after a crash (ROLLBACK) ...  orders = 1   outbox = 1     ← the rolled-back attempt left NEITHER

The rolled-back transaction — an app that died before COMMIT — added nothing: no order, no event. The dual write's fatal gap is closed, and it's closed by the database's own atomicity, not by hope. Notice what just happened to the event: it rode into the database on the same commit — the same write-ahead-log record (WAL & Durability: Why Committed Data Survives) — as the data it describes. It is now durably recorded. The only job left is to get it from the outbox to the queue, and that job can fail, retry, and restart as many times as it likes without ever losing the event, because the event is safe in the table. That shipping job is the relay.

The Relay: Poll the Table, or Tail the Log

The relay is a separate process whose only job is: read committed outbox rows and publish them to the queue. There are two ways to build it, and the choice is a real tradeoff.

1 · Poll the table. The simplest relay is a loop: SELECT the unpublished rows, publish them, mark them done. To run several relay workers without any two grabbing the same row, use the pessimistic claim from Pessimistic vs Optimistic Locking:

SELECT id, payload FROM outbox
 WHERE NOT published
 ORDER BY id
 FOR UPDATE SKIP LOCKED   -- each worker claims a DIFFERENT batch
 LIMIT 100;
-- …publish to the queue…
UPDATE outbox SET published = true WHERE id = ANY(:ids);

Polling works anywhere — any database, no special features. The costs: it adds query load on your database, and it has polling latency (an event waits until the next poll). Tune the interval and you're trading freshness against load.

2 · Tail the log (Change Data Capture). Here's the elegant one, and it's the payoff of a spiral we planted long ago. Your database already writes every committed change to its write-ahead log — that's what makes commits durable (WAL & Durability). CDC simply reads that log and streams the changes to the queue. No polling, no extra queries — it consumes the log the database was writing anyway. The canonical tool is Debezium, which tails the PostgreSQL WAL (via logical decoding) or the MySQL binlog.

This isn't hand-waving — Postgres exposes the exact mechanism, so we can watch CDC work. Create a logical replication slot (what Debezium attaches to), make two committed changes and one that rolls back, then read the log:

SELECT pg_create_logical_replication_slot('cdc_slot', 'test_decoding');
-- INSERT id=5 (commit) · INSERT id=6 (ROLLBACK) · UPDATE id=5 (commit)
SELECT data FROM pg_logical_slot_get_changes('cdc_slot', NULL, NULL);
BEGIN
table public.orders: INSERT: id[integer]:5 item[text]:'mouse' amount[integer]:25
COMMIT
BEGIN
table public.orders: UPDATE: id[integer]:5 item[text]:'mouse' amount[integer]:30
COMMIT

Read that output closely, because it shows three things at once. The committed INSERT id=5 and UPDATE (amount 25 → 30) stream straight out of the WAL. They arrive in commit order. And the rolled-back id=6 is nowhere — CDC surfaces only committed changes, with zero polling of the table. Debezium is doing precisely this, continuously, and tracking an offset (how far through the log it has read) so it can resume after a restart. Log-based CDC has minimal impact on the source, captures every change type (insert/update/delete), and preserves exact order — which is why it wins at scale.

One subtlety: CDC can tail your outbox table, or it can tail your business tables directly (no outbox at all). The direct approach is tempting — one less table — but then your event schema is welded to your table schema: rename a column and you break every consumer. The outbox keeps an explicit, stable event contract, decoupled from how you happen to store rows. That decoupling is usually worth the extra table.

The two ways a relay drains the outbox, side by side. On the left, POLLING: a worker repeatedly runs SELECT the unpublished outbox rows FOR UPDATE SKIP LOCKED, publishes them to the queue, and marks them published; labelled simple and works anywhere, but it adds database load and polling latency. On the right, CHANGE DATA CAPTURE: a connector such as Debezium tails the database write-ahead log — the same log from WAL & Durability that makes commits durable — and streams committed changes straight to the queue; a real Postgres logical-decode is shown, with a committed INSERT and UPDATE appearing in commit order while a rolled-back change never appears, labelled low latency, low load, exact order, no polling. The bottom line: polling asks the table over and over; CDC reads the log the database already writes.

The Catch: At-Least-Once, Never Exactly-Once

The outbox guarantees your event is never lost. It does not guarantee it arrives exactly once — and that gap is not a bug you can fix, so you'd better design for it.

Look again at the relay's two steps: publish the event, then mark the row published. Those touch two systems (the queue and the database) — which is the dual write all over again, just moved downstream. If the relay publishes, then crashes before it marks the row done, the row still looks unpublished. On restart, the relay finds it and publishes it again. The event goes out twice. So the real guarantee is at-least-once: never zero, sometimes more than one.

Can't we just make the relay publish-and-mark atomically and get exactly-once? No — and this is fundamental, not a tooling gap. Exactly-once delivery over an unreliable network is impossible — it's the two-generals result from The Distributed Transaction Problem: no finite exchange of messages can guarantee both sides agree a message arrived exactly once. (Kafka's "exactly-once semantics" is a closed-system trick inside Kafka; it does not extend to an arbitrary consumer reading from it.)

So we stop fighting delivery and fix processing instead. Make the consumer idempotent: processing the same event twice has no additional effect. The mechanism is the event id from §2. Every retry reuses the same id; the consumer keeps a processed_events table (event id as primary key) and refuses to act on an id it has already seen:

INSERT INTO processed_events (event_id) VALUES ('…') ON CONFLICT DO NOTHING;

The first time an event arrives, that insert takes; the second time — the duplicate — it's a no-op, and the consumer skips it. On real Postgres, delivering the same event twice reads exactly like this:

first delivery  ...  INSERT 0 1     ← new id, recorded → process it
duplicate       ...  INSERT 0 0     ← already seen → skip (a no-op)

INSERT 0 1, then INSERT 0 0. Same event, delivered twice, processed once. That is the whole trick, and it has a name: at-least-once delivery + an idempotent consumer = "effectively-once." You don't get exactly-once on the wire; you get exactly-once effects, which is the thing you actually cared about. (Idempotency is important enough that it's the entire next lesson — here it's just the consumer's half of the outbox contract.)

Build the Pipeline Yourself

Three ideas, one moving system: an atomic write, a relay, an idempotent consumer. The way to feel how they fit is to run the pipeline and break it on purpose — because the whole value of the outbox only shows up at the moment of a crash.

Below is the live pipeline: App → Database → Relay → Queue → Consumer. First flip it to naive dual-write and crash the app right after the database commit — the event is simply gone, exactly the failure that opened this section. Now flip to the outbox and inject the same crash at the same spot: the event was committed with the order, so it's safe in the outbox, and the relay delivers it on recovery. Same crash, opposite outcome — that contrast is the lesson. Finally, crash the relay mid-publish and watch it re-publish on restart: the queue holds the event twice, and the idempotent consumer deduplicates it away. Hammer the tally and watch the naive path leak events while the outbox never does.

Drive the pipeline yourself: App → Database → Relay → Queue → Consumer. Toggle between the naive dual-write and the outbox, then inject a crash. Crash the app right after the database commit — the naive path LOSES the event (the order is saved, the world never hears), while the outbox path SURVIVES the very same crash, because the event was committed with the data and the relay delivers it on recovery. Then crash the relay mid-publish and watch it re-publish on restart: the queue holds the event twice (at-least-once), and the idempotent consumer skips the duplicate — effectively-once. A running tally lets you hammer it: the naive path leaks events; the outbox never does.

When to Use What

The decisions almost make themselves once the pieces are clear:

  • Reliable events from a database → use the outbox. Always. It's not exotic microservices esoterica; it's the standard, boring, correct way to emit an event whenever you must change data and tell the world. The cost is one extra row per event (in the same transaction — cheap) plus running a relay.
  • Relay by polling vs CDC. Start with polling when you want simplicity, have no special database features, and can tolerate a little latency — it works everywhere. Move to CDC (Debezium) when you need low latency, low database load, and exact ordering at scale, and you're willing to operate the connector and its log slot.
  • Outbox table vs log-based CDC on business tables. Prefer the outbox table when you want a clean, stable event contract decoupled from your schema (almost always). Tail business tables directly only when you truly can't add an outbox and accept that your events mirror your table layout.
  • Every consumer must be idempotent. At-least-once is the guarantee you get; dedup by event id is the price of admission. Never deploy a consumer that breaks when it sees the same event twice.

Step back and the shape is clean. The dual write failed because it wrote to two systems with no shared transaction. The outbox turns it into one atomic write (data + event, both-or-neither) plus a reliable relay (poll the table, or tail the WAL with CDC) — and closes the one remaining gap, duplicate delivery, not by chasing the impossible exactly-once, but by making the consumer not care. Never lost, maybe twice, processed once.

That last clause — processed once — has been doing a lot of quiet work. "Make the consumer idempotent" is easy to say and surprisingly full of traps: what's a good idempotency key, how long do you remember it, which operations are naturally idempotent and which you have to force. That's not a footnote; it's a discipline, and it's next — Idempotency as a Discipline.