The relay is a separate decision from the outbox table itself
Writing an event into an outbox table in the same transaction as your business write solves the
dual-write problem - but the row still has to leave the table and land on your message broker. That's
the job of the relay, and it's a genuinely separate engineering decision. A polling worker that queries
WHERE published = false on an interval is the obvious first build. Change Data Capture is
the other option, and it works completely differently: instead of asking the database "anything new?"
on a timer, it reads the database's own internal transaction log directly.
How CDC actually works
Every mainstream relational database keeps a durable, ordered log of every row change for its own crash recovery - Postgres calls it the write-ahead log (WAL), MySQL calls it the binlog. A CDC tool attaches to that log as a replication client, the same mechanism the database already uses to replicate to standby servers. It sees every insert, update, and delete the instant it's committed, in commit order, with no polling and no query load on the table it's watching.
-- Postgres: CDC needs logical replication enabled and a publication -- defined on the outbox table specifically. ALTER SYSTEM SET wal_level = logical; CREATE PUBLICATION outbox_pub FOR TABLE outbox; -- Debezium's Postgres connector then subscribes to this publication -- via a replication slot - a durable bookmark of how far it's read.
Debezium is the connector most teams reach for - it runs as a Kafka Connect plugin, supports Postgres,
MySQL, MongoDB, and SQL Server, and turns each row change into a structured Kafka message automatically.
For an outbox table specifically, Debezium ships an EventRouter single-message transform
that unwraps the outbox row's payload column and republishes it as a clean domain event on
a topic named after aggregate_id - so consumers see OrderConfirmed events, not
raw outbox table rows.
{
"name": "outbox-connector",
"config": {
"connector.class": "io.debezium.connector.postgresql.PostgresConnector",
"database.hostname": "prod-db.internal",
"table.include.list": "public.outbox",
"transforms": "outbox",
"transforms.outbox.type": "io.debezium.transforms.outbox.EventRouter",
"transforms.outbox.table.field.event.id": "id",
"transforms.outbox.table.field.event.key": "aggregate_id",
"transforms.outbox.table.field.event.payload": "payload",
"transforms.outbox.route.by.field": "event_type"
}
}Polling vs. CDC, honestly compared
Polling relay
Latency bounded by your poll interval (typically 200ms-2s). Adds a recurring query to the outbox table on every service instance running the worker. Trivial to build and reason about - it's a loop and a query, nothing else to operate.
CDC-based relay
Latency in the low milliseconds - it's notified the instant the transaction commits, not on the next poll tick. Zero added query load on the outbox table. Costs you a Kafka Connect cluster (or equivalent) to operate, monitor, and keep patched.
Where CDC actually breaks in practice
- Schema changes on the source table can silently break a running connector if a column is dropped or renamed without updating the connector config - this is the single most common CDC production incident.
- The initial snapshot (CDC tools read the table's current state once before switching to log-tailing) can take a long time and add load on a large existing table - plan the cutover, don't do it live during peak traffic.
- Replication slots on Postgres are not free - an idle or crashed connector holds a slot open, and an open slot prevents WAL segments from being recycled, which can fill your disk if left unmonitored.
- CDC does not remove the need for idempotent consumers. It changes how the event gets from the database to the broker - it does not upgrade at-least-once delivery to exactly-once. The same idempotency-key pairing from the outbox pattern still applies downstream.
When to actually reach for CDC
Start with polling. If your outbox volume is a few hundred events a minute and 500ms of added latency is invisible to your users, a polling worker is less to operate and easier for a new engineer to understand at 2am during an incident. Move to CDC when polling latency becomes visibly user-facing, when polling load starts showing up on your database's slow-query dashboard, or when you're already running Kafka Connect for other connectors and the marginal cost of one more is small.
Key takeaways
- CDC reads the database's own write-ahead log directly instead of polling a table - near-zero latency, zero added query load.
- Debezium is the standard CDC tool, and its
EventRoutertransform is purpose-built for turning outbox rows into clean domain events. - CDC trades operational simplicity for latency: you're now running and monitoring a Kafka Connect cluster, not just a background loop.
- Schema drift on the source table and abandoned replication slots are the two most common CDC production incidents - monitor both.
- CDC changes the relay mechanism, not the delivery guarantee - consumers still need idempotency keys regardless of which relay you choose.
Zetrixweb