SystemDesign.website
Track 5 ยท Module 5.5 ยท Communication Patterns & Asynchronous Systems

Transactional Outbox & CDC

Two lines of code โ€” db.save(order) then queue.publish(event) โ€” contain one of the most expensive bugs in distributed systems. Master the dual-write problem, the Transactional Outbox that fixes it, and the Change Data Capture machinery that makes it fast.

โฑ๏ธ Reading Time: ~22 min
๐ŸŽฏ Core Architecture: Atomic State Change + Event Emission
๐ŸŽฌ Format: Postal Analogy + Crash Injection Lab

๐Ÿ’ฅ 1. The Two-Line Bug: The Dual-Write Problem

Here is code that ships to production every day. It looks correct. It passes every test. It is broken, and it will corrupt your data in a way no amount of retrying can fix.

โŒ THE BUG โ€” two systems, no shared transaction
@Transactional
def place_order(cart):
    order = db.orders.insert(cart)          # โ‘  writes to PostgreSQL
    kafka.publish("order.created", order)   # โ‘ก writes to Kafka
    return order
                                            # โ‘ข transaction COMMITs here

# @Transactional covers โ‘ . It has NO power over โ‘ก.
# Kafka has never heard of your database transaction and cannot roll back.
There are four ways this goes wrong, and all four happen in production:
โ‘  DB commits, publish fails โ†’ the LOST event

The order exists. No event was ever emitted. The warehouse never picks it, the confirmation email never sends, the analytics pipeline never counts it. The customer's money is taken and nothing happens. Worst of all: this failure is invisible. There is no error to alert on โ€” the request returned 200.

โ‘ก Publish succeeds, DB rolls back โ†’ the PHANTOM event

A constraint violation or deadlock aborts the transaction after the event is already on the wire. Downstream services now process order-9931, which does not exist. The warehouse ships goods for an order that was never placed. Support gets a ticket no one can reproduce, because the row genuinely isn't there.

โ‘ข Publish succeeds, process crashes before COMMIT โ†’ phantom, again

Swapping the order of the two calls does not help โ€” it just changes which failure you get. There is no ordering of two independent writes that makes them atomic. That is the whole point.

โ‘ฃ Publish times out, you retry โ†’ the DUPLICATE event

The publish actually succeeded but the ack was lost, so your retry emits it twice. The customer is charged twice. This one never fully goes away โ€” it's the reason idempotent consumers are mandatory even after you fix everything else.

๐Ÿ’ก The Core Insight:You cannot atomically write to two independent systems. Full stop. Any code that does write(A); write(B) and needs both-or-neither is broken โ€” the only question is how often. The fix is never "add retries" or "be more careful." The fix is to make it one write: put the event inside the same database transaction as the data, then move it to the broker afterwards as a separate, retryable step.
๐Ÿšซ "Why not just use a distributed transaction (2PC / XA)?"

It's the textbook answer and it is almost always wrong in practice:

โ€ข The coordinator is a single point of failure. If it dies mid-protocol, participants hold locks and block โ€” indefinitely โ€” waiting for a verdict that never comes.
โ€ข You hold database locks across a network round trip to the slowest participant. Throughput collapses under contention.
โ€ข Most modern brokers don't support XA at all. Kafka has transactions, but they are Kafka-to-Kafka โ€” they cannot enlist your Postgres.
โ€ข Cloud-native systems have abandoned it. No managed queue you'd pick in 2026 offers XA.

The Outbox pattern gets you the same practical guarantee โ€” no lost events, no phantom events โ€” using nothing but a database transaction you already have.

๐Ÿ“ฌ 2. The Fix: Write the Letter Before You Mail It

โœ‰๏ธ The Analogy: The Outbox Tray

You sign a contract and must post a copy to your partner. The naive version: sign it, then walk to the post box. If you trip on the way, the contract is signed but never sent โ€” and nobody knows.

The reliable version: sign the contract and drop the copy in your outbox tray as one motion, at your desk. Both happen or neither does. Separately, a mail clerk empties the tray every few minutes. If the clerk is sick for an hour, nothing is lost โ€” the letters simply sit there. When the clerk returns, they go out. The tray is durable; the trip to the post box is retryable.

๐ŸŒ‰ The Bridge to Architecture

The tray is a table in the same database as your business data. Because it's the same database, one transaction covers both writes:

โœ… THE FIX โ€” one transaction, one system
BEGIN;

  INSERT INTO orders (id, user_id, total_cents, status)
  VALUES (9931, 42, 15900, 'PLACED');

  -- The event is now just... another row. Same DB. Same transaction.
  INSERT INTO outbox (aggregate_type, aggregate_id, event_type, payload)
  VALUES ('order', '9931', 'order.created',
          '{"order_id":9931,"user_id":42,"total_cents":15900}'::jsonb);

COMMIT;   -- ATOMIC. Both rows, or neither. The DB guarantees it.

-- Publishing to Kafka is now a SEPARATE, RETRYABLE step performed by
-- a different process reading this table. If it fails, the row waits.
-- Nothing is ever lost, and nothing phantom is ever emitted.
The outbox table schema
CREATE TABLE outbox (
  id              BIGSERIAL PRIMARY KEY,   -- monotonic = a natural cursor
  aggregate_type  TEXT        NOT NULL,      -- 'order'  -> often the topic name
  aggregate_id    TEXT        NOT NULL,      -- '9931'   -> the PARTITION KEY
  event_type      TEXT        NOT NULL,      -- 'order.created'
  payload         JSONB       NOT NULL,
  created_at      TIMESTAMPTZ NOT NULL DEFAULT now(),
  published_at    TIMESTAMPTZ                -- NULL = still in the tray
);

-- PARTIAL index: only unpublished rows are indexed, so the index stays
-- tiny (a few hundred rows) even when the table holds billions.
-- Without this, your drain query degrades as the table grows.
CREATE INDEX outbox_unpublished
  ON outbox (id) WHERE published_at IS NULL;
โš ๏ธ What the Outbox does NOT give you

It converts "maybe zero, maybe one" into "at least one." It does not give you exactly-once. The publisher can crash after Kafka accepts the message but before it marks the row published โ€” so on restart it publishes again. This is unavoidable and by design: duplicates are recoverable, lost events are not. Every downstream consumer must therefore be idempotent (see ยง6).

๐Ÿšš 3. Two Ways to Drain the Tray

The rows are safely in the outbox. Something must now move them to the broker. There are exactly two mechanisms, and the choice is one of the more consequential decisions in this pattern.

APolling Publisher โ€” a loop that reads the table
BEGIN;

SELECT id, aggregate_id, event_type, payload
FROM outbox
WHERE published_at IS NULL
ORDER BY id                    -- preserves per-aggregate ordering
LIMIT 500
FOR UPDATE SKIP LOCKED;      -- โ† THE critical clause

-- SKIP LOCKED lets you run N publishers safely: each grabs a DIFFERENT
-- batch instead of all of them blocking on the same rows. Without it,
-- publisher #2 waits for #1 and your "scaled out" fleet is serial.

-- ... publish the batch to Kafka, wait for acks=all ...

UPDATE outbox SET published_at = now() WHERE id = ANY($1);
COMMIT;
โœ… Wins: no extra infrastructure, no DB privileges beyond normal SQL, trivially debuggable, works on any database, easy to unit test.
โŒ Costs: latency is bounded below by your poll interval; constant query load on your primary even when idle; every publisher competes for the same table.
Throughput math: batch of 500 rows every 100 ms = 5,000 events/s per publisher, with a worst-case added latency of 100 ms (an event that lands just after a poll waits a full interval). Tighten the interval and you pay in query load; widen it and you pay in latency.
BLog Tailing / CDC โ€” read the database's own replication log

Every ACID database already maintains a durable, ordered log of every committed change โ€” Postgres calls it the WAL, MySQL the binlog. That log is how replicas stay in sync. Change Data Capture means attaching to it and treating committed rows as a stream of events. You are not polling for changes; the database is telling you about them.

โœ… Wins: sub-10 ms propagation; zero query load on your tables; ordering comes free from the log; captures changes made by anything โ€” migrations, admin tools, other services.
โŒ Costs: real infrastructure to run (Kafka Connect + Debezium); needs elevated DB privileges (REPLICATION); DB-version-coupled; and one genuinely dangerous failure mode โ€” see ยง5.
DimensionPolling PublisherCDC / Log Tailing
Added latencyโš ๏ธ Up to your poll interval (~100 ms)โœ… Sub-10 ms
Load on the primaryโŒ Constant queries, even when idleโœ… Effectively none
Operational weightโœ… A cron job / worker you already knowโŒ Connect cluster, connectors, offsets, monitoring
Ordering guaranteeโš ๏ธ You maintain it with ORDER BY idโœ… Inherent โ€” it's the commit log
Blast radius when brokenโœ… Events queue up in a tableโŒ Can fill the disk and take the DB down
Right forMost teams. Start here.High volume, tight latency, or many tables
๐Ÿ’ก The Engineering Judgement:Start with the polling publisher. It is ~60 lines of code, needs nothing you don't already run, and at 5,000 events/s per worker it carries most companies to a scale they will never reach. Graduate to CDC when you have a concrete reason: p99 latency budgets under 50 ms, tens of thousands of events per second, or a dozen services all needing change streams. Reaching for Debezium on day one buys you a Kafka Connect cluster to operate and a new way to take your primary database offline.

๐Ÿ”ฌ 4. Inside CDC: WAL, Binlog & Debezium

๐Ÿ˜ PostgreSQL โ€” Logical Replication
wal_level = logical โ€” the WAL normally holds physical block diffs, useless for row events. This setting makes it also emit logical row changes. Requires a restart.
Replication slot โ€” a durable, server-side bookmark. It guarantees Postgres retains WAL until your consumer confirms it. This is the safety mechanism and the footgun.
LSN โ€” Log Sequence Number, the byte-offset cursor into the WAL. Your consumer's position is an LSN.
pgoutput โ€” the built-in output plugin (no extension needed). wal2json is the older alternative.
REPLICA IDENTITY FULL โ€” without it, UPDATE events carry only the key in before. Set it if you need old values โ€” at a real WAL-size cost.
๐Ÿฌ MySQL โ€” Binary Log
binlog_format = ROW โ€” mandatory. The default STATEMENT format logs the SQL text, so UPDATE โ€ฆ WHERE status='new' tells you nothing about which rows changed.
binlog_row_image = FULL โ€” include every column, not just changed ones.
GTID โ€” Global Transaction ID. Gives a stable cursor that survives failover to a replica; file+position does not.
expire_logs_days โ€” MySQL deletes old binlogs on a timer. Unlike a Postgres slot, it will not wait for you: a consumer down too long loses data permanently and needs a full re-snapshot.
What Debezium actually emits

Debezium is a set of Kafka Connect source connectors that speak these replication protocols and turn each row change into a structured event with before and after images:

{
  "op": "c",                      // c=create  u=update  d=delete  r=read (snapshot)
  "before": null,                // populated on u/d (needs REPLICA IDENTITY FULL)
  "after": {
    "id": 18,
    "aggregate_id": "9931",
    "event_type": "order.created",
    "payload": "{\"order_id\":9931,\"total_cents\":15900}"
  },
  "source": {
    "db": "shop", "table": "outbox",
    "lsn": 24857392,          // exact WAL position โ€” your resume cursor
    "txId": 4412,             // group events from one transaction
    "snapshot": "false"
  },
  "ts_ms": 1787923200000
}
๐ŸŽฏ The Outbox Event Router โ€” why you still want an outbox table with CDC

A tempting shortcut is to point Debezium straight at your orders table and skip the outbox entirely. Don't. That publishes your internal schema as your public event contract โ€” every consumer now breaks when you rename a column, and you can never refactor. It also means you can only emit events that happen to correspond exactly to a row shape.

Instead, capture the outbox table. Debezium's EventRouter SMT then unwraps each row: aggregate_type becomes the destination topic, aggregate_id becomes the Kafka message key (which is what preserves per-order ordering inside a partition), and payload becomes the message body. Consumers see a deliberately designed event, not your table.

๐Ÿšจ 5. The Outage: How CDC Takes Down Your Database

This is the single most important operational fact in this module, and it surprises teams every year. A stalled replication slot can take your primary database down.

โ‘  Your Debezium connector dies at 2 a.m. โ€” a bad deploy, a Connect OOM, an expired credential. Nobody is paged, because the application is perfectly healthy.
โ‘ก The replication slot still exists, and it is doing exactly its job: guaranteeing WAL is retained until the consumer confirms it.
โ‘ข Nobody confirms anything. So Postgres cannot recycle a single WAL segment. The WAL directory grows monotonically.
โ‘ฃ The disk fills. Postgres cannot write WAL, so it cannot accept any write at all. Your entire application goes down โ€” because a downstream analytics pipeline was broken.
๐Ÿ“ The math at a modest 5 MB/s of WAL generation
  5 MB/s ร— 3600 s  =   17.6 GB pinned after  1 hour
  5 MB/s ร— 6 h     =  105.5 GB pinned after  6 hours   โ† overnight
  5 MB/s ร— 24 h    =  421.9 GB pinned after 24 hours

An unnoticed weekend outage will exhaust almost any volume you provisioned.
A busy primary generating 50 MB/s reaches 176 GB in ONE hour.
๐Ÿ›ก๏ธ What to actually do about it
โ€ข Alert on slot lag, not on connector health. Query pg_replication_slots and alarm on pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn) crossing a few GB. This is the metric that predicts the outage; connector uptime does not.
โ€ข Set max_slot_wal_keep_size (PG 13+). It caps retention: past the limit, Postgres invalidates the slot instead of dying. You lose the stream and must re-snapshot โ€” a far better outcome than a total outage. Choose your failure mode deliberately.
โ€ข Alarm on WAL directory free space as an independent backstop, with a runbook whose first line is "drop the inactive slot."
โ€ข Delete slots you stop using. An orphaned slot from a decommissioned pipeline is a time bomb with no owner.
โ€ข MySQL fails the opposite way: binlogs expire on a timer regardless of consumers, so your DB stays up but the consumer silently loses data and needs a full re-snapshot. Neither default is safe โ€” know which one you have.

๐Ÿ” 6. The Other Half: Idempotent Consumers

The outbox guarantees at-least-once delivery. Half the work is therefore on the consumer: duplicates will arrive, and "charge the customer" must not run twice.

-- The INBOX (dedup) table, in the CONSUMER's database
CREATE TABLE processed_events (
  event_id     UUID PRIMARY KEY,        -- the producer's event id
  processed_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

-- Handling an event: the dedup check and the side effect must share ONE
-- transaction. Otherwise you have re-created the dual-write problem here.
BEGIN;
  INSERT INTO processed_events (event_id) VALUES ($1)
  ON CONFLICT (event_id) DO NOTHING;
  -- 0 rows affected => already handled => COMMIT and ack. Do nothing else.

  -- 1 row affected => first time => perform the side effect HERE:
  UPDATE inventory SET reserved = reserved + 1 WHERE sku = $2;
COMMIT;
โœ… Better: make the operation naturally idempotent

A dedup table is a crutch. Prefer designs where replay is harmless: UPDATE โ€ฆ SET status='PAID' (assignment, not increment); INSERT โ€ฆ ON CONFLICT DO NOTHING on a natural key; upserts keyed by the event's aggregate id. If the operation is idempotent by construction, duplicates cost you nothing and you store no state at all.

โš ๏ธ Ordering is per-key, never global

Kafka guarantees order within a partition only. Use aggregate_id as the message key so every event for order 9931 lands in the same partition and is processed in sequence. Key by anything else โ€” random, round-robin โ€” and order.shipped can be handled before order.created.

๐Ÿ—‘๏ธ Table hygiene โ€” both tables will eat your disk

Neither the outbox nor the inbox is self-cleaning, and both grow at your full event rate.

Dedup table at 5,000 events/s with 7-day retention, ~48 B per row:

  5,000/s ร— 86,400 s ร— 7 d = 3.02 billion rows
  3.02e9 ร— 48 B            = 135.2 GB  of pure bookkeeping

Use time-based partitioning and DROP whole partitions โ€” a metadata operation. Do not use a nightly DELETE: mass deletes leave dead tuples, trigger autovacuum storms on your primary, and bloat the very index your publisher depends on. This is the #1 way a correctly-implemented outbox still causes an incident six months later.

๐Ÿ’ป 7. Production Code: Outbox Write + Publisher (Multi-Language)

Each version shows the two halves: the atomic write (business row + outbox row in one transaction) and the drain loop with SKIP LOCKED so you can run many publishers safely.

Select Language:
# Python โ€” psycopg3 + confluent-kafka
import json, time, uuid
import psycopg
from confluent_kafka import Producer

# โ”€โ”€ HALF 1: the atomic write โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
def place_order(conn, cart):
    # ONE transaction. Both inserts, or neither. No broker involved.
    with conn.transaction():
        order_id = conn.execute(
            "INSERT INTO orders (user_id, total_cents, status) "
            "VALUES (%s, %s, 'PLACED') RETURNING id",
            (cart.user_id, cart.total_cents)).fetchone()[0]

        conn.execute(
            "INSERT INTO outbox "
            "  (event_id, aggregate_type, aggregate_id, event_type, payload) "
            "VALUES (%s, 'order', %s, 'order.created', %s)",
            (str(uuid.uuid4()), str(order_id),
             json.dumps({"order_id": order_id,
                         "user_id": cart.user_id,
                         "total_cents": cart.total_cents})))
    return order_id

# โ”€โ”€ HALF 2: the drain loop (run N of these) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
BATCH = 500
POLL_S = 0.1   # 500 rows / 100ms = 5,000 events/s per worker

def drain_forever(dsn, producer):
    with psycopg.connect(dsn, autocommit=False) as conn:
        while True:
            n = drain_once(conn, producer)
            # Only sleep when idle: under load we loop with zero added latency.
            if n < BATCH:
                time.sleep(POLL_S)

def drain_once(conn, producer):
    with conn.transaction():
        rows = conn.execute(
            """SELECT id, event_id, aggregate_type, aggregate_id,
                      event_type, payload
                 FROM outbox
                WHERE published_at IS NULL
                ORDER BY id
                LIMIT %s
                FOR UPDATE SKIP LOCKED""",   # โ† lets workers scale
            (BATCH,)).fetchall()

        if not rows:
            return 0

        for (_id, event_id, agg_type, agg_id, ev_type, payload) in rows:
            producer.produce(
                topic=f"{agg_type}.events",
                key=agg_id,          # โ† per-aggregate ORDERING
                value=json.dumps(payload),
                headers=[("event_id", event_id),   # โ† consumer dedup key
                         ("event_type", ev_type)])

        # flush() BEFORE the UPDATE. If flush raises, the transaction rolls
        # back, rows stay unpublished, and we retry. Marking first would
        # mean silently LOSING events โ€” the exact bug we came here to fix.
        if producer.flush(timeout=10) > 0:
            raise RuntimeError("kafka flush incomplete; will retry batch")

        conn.execute("UPDATE outbox SET published_at = now() "
                     "WHERE id = ANY(%s)", ([r[0] for r in rows],))
    return len(rows)
๐Ÿ”‘ The One Ordering Rule

Publish, then mark. Never mark, then publish. If you mark rows as published and then crash before Kafka acks, those events are gone forever with no trace โ€” you have re-introduced the lost-event bug inside the very mechanism built to prevent it. Publishing first means a crash produces a duplicate, which your idempotent consumer absorbs. Always choose the failure mode that is recoverable.

๐Ÿงช 8. Interactive Lab: Crash Injection

๐Ÿ’ฅ Break It On Purpose

Dual Write vs Outbox โ€” Kill the Process Mid-Sequence

Pick a strategy, pick where it dies
1 ยท Strategy
2 ยท Crash after stepnone
0 = no crash ยท slide right to kill the process later
Execution timeline

๐Ÿ’ก Try this: with Dual Write, crash after step 2 โ€” the event is on the broker but the order never committed (a phantom). Then crash after step 1 โ€” the order exists and no event was ever emitted (a silently lost event). Now switch to Outbox and try every crash point: you can produce a duplicate, and you can produce a delay, but you can never produce a lost or phantom event.

๐ŸŽฏ 9. Test Your Understanding

Scenario 1: The Retry "Fix"

A colleague acknowledges the dual-write risk and proposes wrapping the Kafka publish in a retry loop with exponential backoff, plus an alert if all retries fail. Is the problem solved?

Scenario 2: The 3 A.M. Disk Alert

You run Debezium against Postgres. At 4 a.m. your primary database starts refusing all writes. Disk is 100% full. Application error rates are at 100%, but the app itself never changed and traffic is at its overnight low. What happened, and what do you check first?

Scenario 3: The Publisher Optimization

To cut publisher latency, an engineer reorders the drain loop: mark the batch published_at = now() and commit immediately, then send to Kafka asynchronously without waiting for acks. Throughput doubles. What did they just break?

โ—‹

Completed this module?

Mark as finished to update your curriculum progress tracker.