Transactional outbox and change data capture
In one line
The transactional outbox writes a business change and the event describing it in one database transaction, and a relay or change data capture (CDC) process publishes that event afterwards, so the database and the message stream can never disagree.
What it is
Many services must update their database and also tell others about it: save the order, then publish OrderPlaced to Kafka. Doing these as two separate steps is the dual-write problem. If the database commit succeeds and the publish fails (broker down, process killed), the order exists but nobody hears about it. If you publish first and the commit fails, consumers act on an order that does not exist. There is no shared transaction across Postgres and Kafka.
The transactional outbox fixes this by using only the database's own transaction. The service inserts the business row and an outbox row (the event) in the same local transaction. Either both commit or neither does. A separate relay then reads committed outbox rows and publishes them to the broker, retrying until it succeeds. Delivery is at-least-once, so consumers deduplicate by event id.
Change data capture (CDC) reads the database's replication log (Postgres WAL via logical decoding, MySQL binlog, MongoDB change streams, DynamoDB Streams) and turns each committed insert, update and delete into an event. Debezium is the most common open-source tool; it runs on Kafka Connect. CDC can be used two ways: tail the outbox table (the outbox pattern with a log-based relay; Debezium ships an outbox event router for this), or capture changes from the business tables directly (useful for syncing search indexes, caches and warehouses).
The difference matters. Outbox events are designed, stable domain events (OrderPlaced with the fields consumers need). Raw table CDC exposes your internal schema to every consumer, so a column rename can break them. Use outbox for service-to-service events, raw CDC for data replication you own end to end.
Why interviewers ask
Almost every event-driven design has a 'save and publish' step: orders, payments, inventory, user signups. Interviewers ask 'what if the service crashes after writing to the database but before publishing?' to see whether you know the dual-write problem. The outbox or CDC answer, plus idempotent consumers, is what they want. CDC is also the standard answer for keeping Elasticsearch, Redis or a data warehouse in sync with the source database without dual writes.
How it works
- 1Create an outbox table: id (UUID, used as the event id), aggregate_type, aggregate_id (the partition key, for example order_id), event_type, payload (JSON or Avro/Protobuf bytes), created_at, and published_at for the polling approach.
- 2In the same transaction as the business change, insert the outbox row. No call to Kafka happens inside the request path, which also keeps latency low.
- 3Relay option A, polling: a worker selects unpublished rows in order with FOR UPDATE SKIP LOCKED, publishes them with key = aggregate_id, then marks them published. Simple, works anywhere, but adds polling load and some delay (typically 100 ms to 1 s).
- 4Relay option B, log tailing: Debezium reads the Postgres WAL through a logical replication slot (pgoutput plugin) and emits each new outbox row to Kafka, routing by event type and keying by aggregate id. Lower latency, no polling queries, but more infrastructure.
- 5Keep the outbox small: delete published rows after a short time (or, with log-based CDC, delete them right after insert in the same transaction, since the WAL still contains the insert).
- 6Ordering: use the aggregate id as the Kafka key so events for one order stay in one partition. The WAL already gives commit order; with polling, order by an increasing column per aggregate.
- 7Consumers are idempotent: they record the outbox id as processed in the same transaction as their own side effect (see delivery guarantees).
- 8Operate CDC carefully: a Postgres replication slot keeps WAL until the connector confirms it. If the connector stops for hours, WAL piles up and can fill the disk, so alert on slot lag and set max_slot_wal_keep_size.
Worked example · Order placed on an e-commerce site during a sale
- Checkout writes 3,000 orders per second at peak. Each request runs one Postgres transaction: insert into orders, order_items and outbox (about 1 KB payload). Kafka is not called in the request.
- If Kafka is down for 5 minutes, checkout keeps working: 3,000 x 300 s = 900,000 outbox rows accumulate, about 900 MB. That is fine for Postgres, and the relay catches up when Kafka returns.
- Debezium tails the WAL and publishes each outbox insert to topic orders.events with key = order_id. Typical end-to-end delay is under a second.
- The process running Debezium restarts after publishing 200 events but before recording its WAL position. It resumes from the last confirmed position and republishes those 200 events.
- The inventory, notification and search consumers each dedupe by event id, so the 200 duplicates cause no double stock reservation or duplicate SMS.
- A nightly job deletes outbox rows older than 3 days; with immediate delete after insert, the table stays almost empty and only the WAL carries the events.
Picture it
A shopkeeper writes the sale in the ledger and, on the same page, writes 'tell the delivery boy'. Even if the shopkeeper faints right after, whoever opens the ledger later sees the note and passes it on. Writing the sale in the ledger and separately shouting to the delivery boy can go wrong if he is not there at that moment.
Reference code
-- Write the business row and the event in ONE local transaction.
BEGIN;
INSERT INTO orders (id, user_id, total, status)
VALUES ('ord_981', 42, 54900, 'PLACED');
INSERT INTO outbox (id, aggregate_type, aggregate_id, event_type, payload, created_at)
VALUES (gen_random_uuid(), 'order', 'ord_981', 'OrderPlaced',
'{"order_id":"ord_981","user_id":42,"total":54900}', now());
COMMIT;
-- Relay (polling version): many relays can run safely with SKIP LOCKED.
-- In one transaction: lock a batch, publish it to Kafka (key = aggregate_id),
-- then mark it sent. A crash before the UPDATE means the batch is published
-- again, so consumers must dedupe on outbox.id.
SELECT id, aggregate_id, event_type, payload
FROM outbox
WHERE published_at IS NULL
ORDER BY created_at
LIMIT 500
FOR UPDATE SKIP LOCKED;
UPDATE outbox SET published_at = now() WHERE id = ANY(:published_ids);Key patterns
Polling outbox relay
When: Small to medium volume, no CDC infrastructure
How: SELECT ... FOR UPDATE SKIP LOCKED in batches, publish, mark published; several relays can run in parallel.
Log-tailing outbox (Debezium outbox router)
When: High volume or low latency needs
How: Debezium reads the outbox inserts from the WAL and routes them to topics by event type, keyed by aggregate id.
Raw table CDC to derived stores
When: Keeping search indexes, caches, data lakes in sync with a database you own
How: Capture row changes, apply them idempotently with version checks, use snapshots to bootstrap.
Listen to yourself
When: A service also needs to react to its own event
How: Consume your own outbox events from Kafka instead of doing the follow-up work inline, so the same path handles all consumers.
Real-world use
Debezium
Open-source CDC connectors (Postgres, MySQL, MongoDB, SQL Server, Oracle and more) running on Kafka Connect or as Debezium Server. It includes an outbox event router transform that turns outbox table rows into clean domain events.
LinkedIn Databus
An early large-scale CDC system that streamed changes from LinkedIn's primary databases to search indexes, caches and other derived stores, instead of applications writing to all of them.
Netflix DBLog
Netflix's CDC framework captures changes from MySQL and Postgres logs and interleaves them with chunked full-table dumps, so new consumers can bootstrap without pausing the log stream.
Airbnb SpinalTap
Airbnb built SpinalTap to capture MySQL binlog changes reliably and publish them for cache invalidation, search indexing and other services.
Cloud-native CDC
DynamoDB Streams, MongoDB change streams, Google Datastream and AWS DMS offer managed change feeds, which often replace a hand-built relay.
In a project
Your product search on Elasticsearch drifts from the Postgres catalogue: some sellers' price changes never appear, because the catalogue service updates Postgres and then calls Elasticsearch directly, and those calls sometimes time out during sale traffic.
- Remove the direct dual write. The catalogue service only writes Postgres.
- Run Debezium against the products and prices tables (raw CDC is fine here, since the search indexer is owned by the same team).
- Publish to Kafka keyed by product_id, so updates to one product apply in order.
- The indexer consumes changes and writes to Elasticsearch with external versioning (version = source LSN or updated_at), so an older update can never overwrite a newer one, even on replay.
- Bootstrap with Debezium's initial snapshot, then switch to streaming. Rebuilding the index later means replaying from a new snapshot.
- Alert on replication slot lag, consumer lag and a nightly count comparison between Postgres and Elasticsearch.
Impact: Search drift drops to zero in daily checks, catalogue writes get faster because they no longer wait on Elasticsearch, and Elasticsearch outages only delay updates instead of losing them.
Trade-offs
Use it when
- A service must change its database and notify other services reliably
- Keeping search indexes, caches or warehouses in sync without dual writes
- The broker may be unavailable at times and the write path must keep working
- Migrating from a monolith: CDC streams changes out of the old database to new services
Avoid it when
- Losing an occasional event is acceptable (analytics pings); a direct publish is simpler
- Exposing raw table CDC to other teams as a public contract; publish designed events instead
- Your database does not offer a usable change log and polling would add heavy load
- The team cannot operate Kafka Connect and replication slots; start with a polling relay
Common mistakes
- Writing to the database and then publishing to Kafka in application code and calling it 'reliable'.
- Publishing to Kafka inside the database transaction; the publish cannot be rolled back if the commit fails.
- Forgetting that the relay is at-least-once, and writing consumers that cannot handle duplicates.
- Keying events randomly, so an order's events arrive out of order.
- Leaving a Postgres replication slot attached to a stopped connector until WAL fills the disk.
- Letting the outbox table grow forever with no cleanup.
- Exposing internal table schemas via CDC to many consumers, freezing your schema.
Interview questions
Quick revision
- Dual write (DB then broker) can lose or invent events.
- Outbox: business row + event row in one local transaction.
- Relay publishes committed outbox rows: polling (SKIP LOCKED) or log tailing (Debezium).
- At-least-once: consumers dedupe on event id.
- Key by aggregate id to keep per-entity order.
- CDC sources: Postgres WAL (logical decoding), MySQL binlog, Mongo change streams, DynamoDB Streams.
- Outbox events = public contract; raw CDC = internal replication.
- Watch replication slot lag; cap WAL kept for slots.
- Clean up the outbox table.
Check yourself
1. A service commits an order to Postgres and then crashes before publishing OrderPlaced. Which pattern prevents the lost event?
2. What delivery guarantee does an outbox relay usually give?
3. What is the main risk when a Debezium connector on Postgres is stopped for a long time?
4. Why prefer outbox events over raw table CDC for events consumed by other teams?
See it in a full design
These walkthroughs use this lesson.
Saved on this device. Create a free account to keep it on every device.
Practise this with Sam
A real mock interview, out loud, with an honest scorecard. Free to start.