What the dual write problem is and why the transactional outbox pattern fixes it

A service that saves an order and then publishes an OrderCreated event is writing to two systems: the database and the message broker. The two writes share no transaction, so one can succeed while the other fails. This is the dual write problem.

Two failure sequences

Commit succeeds, publish fails

  1. Service → DB: INSERT order → COMMIT OK
  2. Service → Broker: publish OrderCreated → timeout / crash
  3. Result: the order exists, but no downstream service ever hears about it.

Publish succeeds, commit fails

  1. Service → Broker: publish OrderCreated → ACK
  2. Service → DB: COMMIT → constraint violation / rollback
  3. Result: consumers act on an order that does not exist.

Why the usual fixes fall short

  • Retries help only while the process is alive. If it crashes between the commit and the publish, nothing is left to retry.
  • Two-phase commit needs both the database and the broker to support a shared coordinator. Many brokers don't, and where it works it adds blocking and couples the availability of the two systems.
  • Publish-after-commit hooks run in application memory, so they fail the same way a crash fails in the first sequence.

The outbox idea

The transactional outbox pattern removes the second write. The service inserts the event into an outbox table in the same local transaction as the business data. A separate relay, here Debezium reading the transaction log, publishes those rows later.

  1. Service → DB: INSERT order + INSERT outbox → single COMMIT
  2. Relay → DB log: reads the committed outbox row
  3. Relay → Broker: publishes, retrying until the broker acknowledges

Either both rows exist or neither does. The database becomes the single source of truth.

The guarantee is at-least-once delivery, not exactly-once. The relay can publish an event, fail before recording its progress, and publish the same event again after restarting. Consumers must therefore handle duplicates.

(A) Dual write: two independent writes, no shared transaction

+---------+  1. INSERT order + COMMIT    +----------+
|         |---------------------------->| Database |  COMMIT OK
|  Order  |                             +----------+
| Service |  2. publish OrderCreated    +----------+
|         |------------X--------------->|  Broker  |  timeout / crash
+---------+                             +----------+
  Result: order exists, event lost.
  Reverse order: event ACKed, then COMMIT rolls back -> ghost event.

(B) Transactional outbox: one local transaction, asynchronous relay

+---------+  1. BEGIN                  +--------------------+
|  Order  |     INSERT orders          |      Database      |
| Service |---> INSERT outbox  ------->|  orders  | outbox  |
|         |     COMMIT (one txn)       +--------------------+
+---------+                                      |
                                                 | 2. read committed
                                                 |    outbox row from log
                                                 v
                                         +---------------+
                                         |   Debezium    |
                                         |  (log relay)  |
                                         +---------------+
                                                 |
                                                 | 3. publish, retry until ACK
                                                 v
                                         +---------------+     +-----------+
                                         |    Broker     |---->| Consumers |
                                         +---------------+     +-----------+
  At-least-once: retries can redeliver an event; consumers must dedupe.

Top: with two independent writes, a crash between commit and publish loses the event (or the reverse order emits a ghost event); bottom: the outbox row commits atomically with the order and Debezium relays it from the transaction log, giving at-least-once delivery.

Designing the outbox table and writing to it in the same transaction

Keep the outbox table narrow. Each row represents one event, and every column has a specific job:

  • id (UUID): the event ID. Generate it in application code so consumers can deduplicate on it. Because delivery is at-least-once, this is the value consumers rely on to discard repeats.
  • aggregatetype: the kind of entity, such as Order. This column typically determines the destination topic.
  • aggregateid: the entity's identifier. When it is used as the message key, all events for one order land in the same partition and stay in order.
  • type: the event name, such as OrderPlaced.
  • payload: the event body.
  • timestamp: when the event was created.

These names are chosen to align with Debezium's outbox router, which is configured in section 4.

The write itself is simple. Open a transaction, insert the row into orders, insert the matching row into outbox, then commit. Both rows commit together or neither does. No broker call happens inside the transaction, so a broker outage cannot block or partially apply a business write.

Payload format and versioning

The outbox decouples consumers from your table schema, but the coupling does not disappear. It moves into the payload, so treat the payload as a public contract. JSON stored in a jsonb or text column is easy to inspect and debug, but nothing enforces its structure. A schema-registry format such as Avro or Protobuf adds compatibility checks at the cost of extra infrastructure.

Whichever format you choose, version your events explicitly. Put the version in the type value or in a payload field. Prefer additive changes, and when a change would break consumers, publish a new version alongside the old one.

-- PostgreSQL. Column names match the Debezium outbox router's defaults.
CREATE TABLE outbox (
  id            uuid         PRIMARY KEY,      -- event ID, generated by the app
  aggregatetype varchar(255) NOT NULL,         -- e.g. 'Order'; drives topic routing
  aggregateid   varchar(255) NOT NULL,         -- message key; keeps per-order ordering
  type          varchar(255) NOT NULL,         -- event name, e.g. 'OrderPlaced'
  payload       jsonb        NOT NULL,         -- the public event contract
  "timestamp"   timestamptz  NOT NULL DEFAULT now()
);

-- Business write and event write share one local transaction.
BEGIN;

INSERT INTO orders (id, customer_id, total_cents, status)
VALUES ('ord-1001', 'cust-42', 4999, 'PLACED');

INSERT INTO outbox (id, aggregatetype, aggregateid, type, payload)
VALUES (
  'b3f1c2d4-5e6f-4a7b-8c9d-0e1f2a3b4c5d',     -- UUID created in application code
  'Order',
  'ord-1001',
  'OrderPlaced',
  '{
     "schemaVersion": 1,
     "orderId": "ord-1001",
     "customerId": "cust-42",
     "totalCents": 4999,
     "currency": "USD"
   }'::jsonb                                   -- version lives in the payload
);

-- Both rows become visible together, or neither does. No broker call here.
COMMIT;

A PostgreSQL outbox table using the Debezium outbox router's default column names, plus a single transaction that inserts an order and its versioned OrderPlaced event.

How Debezium captures change data capture events from the transaction log

Debezium never polls the outbox table. It reads the database's own transaction log, the same stream used for replication, so it sees each committed insert in commit order and adds no query load to the table. Rows from rolled-back transactions never appear, because only committed changes reach the decoded stream.

PostgreSQL

Set wal_level=logical so the write-ahead log keeps enough detail to rebuild row changes. This setting requires a restart. Give the connector a role with the REPLICATION attribute and SELECT on the outbox table. Use the built-in pgoutput decoding plugin. It works with a publication, which limits decoding to the tables you list. Create a publication that contains only the outbox table, so the connector doesn't decode every write in the database.

Debezium also creates a replication slot. The slot records how far the connector has read, which lets it resume after a restart without losing events. The catch is that PostgreSQL retains all WAL the slot hasn't confirmed. A stalled connector can therefore fill the disk. Section 6 covers how to watch for this.

MySQL

Set binlog_format=ROW and binlog_row_image=FULL, and give each server a unique server_id. The connector user needs SELECT, RELOAD, REPLICATION SLAVE and REPLICATION CLIENT.

Registering the connector

You register the connector by POSTing a JSON config to the Kafka Connect REST API at /connectors. For PostgreSQL, the key properties are:

  • connector.class and the database connection details
  • plugin.name set to pgoutput
  • slot.name and publication.name
  • topic.prefix
  • table.include.list, narrowed to the outbox table
curl -sS -X POST  \
  -H 'Content-Type: application/json' \
  -d @- <<EOF
{
  "name": "orders-outbox-connector",
  "config": {
    "connector.class": "io.debezium.connector.postgresql.PostgresConnector",
    "database.hostname": "orders-db.internal",
    "database.port": "5432",
    "database.user": "debezium_repl",
    "database.password": "${DEBEZIUM_DB_PASSWORD}",
    "database.dbname": "orders",

    "plugin.name": "pgoutput",
    "slot.name": "orders_outbox_slot",
    "publication.name": "orders_outbox_pub",

    "topic.prefix": "orders",
    "table.include.list": "public.outbox"
  }
}
EOF

curl -sS

Registers a Debezium PostgreSQL connector through the Kafka Connect REST API, using pgoutput with a named replication slot and publication and limiting capture to the outbox table.

Configuring the Debezium outbox event router

The outbox router is a single message transform (SMT) that runs inside the connector. It turns each raw change event on the outbox table into a clean domain event. Without it, consumers would receive Debezium's change envelope, with before, after, and source blocks, and would have to unwrap it themselves. Register it under transforms with the type io.debezium.transforms.outbox.EventRouter, then map your outbox columns to its settings:

  • route.by.field names the column that selects the destination topic, here aggregatetype. Pair it with route.topic.replacement, for example outbox.event.${routedByValue}. An Order row then lands on outbox.event.Order, and each consumer subscribes only to the aggregates it cares about.
  • table.field.event.key set to aggregateid makes the aggregate id the Kafka message key.
  • table.field.event.payload points at the column holding the serialized event body.
  • table.fields.additional.placement with a value like id:header:eventId copies the event id into a message header. Consumers can then deduplicate without parsing the payload.

Keying by aggregate id matters because Kafka's default partitioner hashes the key. Every event for one order goes to the same partition, and Kafka preserves order within a partition, so consumers see OrderCreated before OrderShipped. There is no ordering across aggregates or across topics. If you increase a topic's partition count, the key-to-partition mapping changes, and ordering for in-flight aggregates can break during the transition.

End-to-end latency is the sum of three stages:

  1. The transaction commit.
  2. Debezium reading and decoding the log.
  3. Connector batching, governed by poll.interval.ms and max.batch.size, plus producer buffering.

Tune those settings before assuming the database is the bottleneck.

name=orders-outbox-connector
connector.class=io.debezium.connector.postgresql.PostgresConnector
database.hostname=orders-db.internal
database.port=5432
database.user=debezium
database.password=change-me
database.dbname=orders
plugin.name=pgoutput
topic.prefix=orders
table.include.list=public.outbox
tombstones.on.delete=false

poll.interval.ms=100
max.batch.size=2048

transforms=outbox
transforms.outbox.type=io.debezium.transforms.outbox.EventRouter
transforms.outbox.route.by.field=aggregatetype
transforms.outbox.route.topic.replacement=outbox.event.${routedByValue}
transforms.outbox.table.field.event.id=id
transforms.outbox.table.field.event.key=aggregateid
transforms.outbox.table.field.event.payload=payload
transforms.outbox.table.fields.additional.placement=id:header:eventId

A Kafka Connect properties file for a Debezium PostgreSQL connector that uses the outbox EventRouter to send each event to a topic for its aggregate type, keyed by aggregate id, with the event id passed as an eventId header.

Building idempotent consumers and keeping the outbox table clean

Why consumers will see duplicates

Debezium provides at-least-once delivery. Kafka Connect commits source offsets periodically, controlled by offset.flush.interval.ms, rather than after every record. If the connector crashes or rebalances after publishing events but before the next offset flush, it resumes from the last committed log position and publishes those events again. Producer retries can also add duplicates. Exactly-once has to be enforced at the consumer.

The consumer should deduplicate on the outbox event id, which the event router forwards as a message header. Keep a processed_events table keyed on that id. In one database transaction, insert the id and apply the business side effects. If the insert violates the primary key, the event was already handled, so roll back and acknowledge it. Commit the Kafka offset only after the database commit. A crash between the two then causes a redelivery, and the primary key check rejects it. Side effects outside that database, such as calling an external API, are not protected by this transaction and need their own idempotency keys.

Keeping the outbox small

The log is the source of truth, not the table. That means you can delete rows right after inserting them. The event router only turns inserts into events, so it ignores the delete.

Strategy Table bloat Write load
Delete in the same transaction Table stays near-empty, and vacuum clears a small, steady stream of dead tuples Each event writes an insert and a delete to the log
Scheduled pruning Table and indexes grow between runs, and large batch deletes leave dead tuples in bursts Low per event, but each prune causes a load spike

If you prune on a schedule, partition the outbox by time and drop old partitions. Dropping a partition avoids the dead tuples that batch deletes leave behind.

Operating it in production: monitoring, replication slots and failure testing

An outbox pipeline fails quietly. The application keeps committing rows, so nothing looks wrong until consumers notice missing events or the primary runs out of disk. Monitor three things.

Connector health

Poll the Kafka Connect REST API with GET /connectors/<name>/status and alert when the connector or any task is not RUNNING. A task can be FAILED while the worker process stays healthy, so a liveness probe on the worker alone will not catch it. Also track Debezium's streaming metrics, such as MilliSecondsBehindSource, so you can see when the connector is falling behind before it stops.

Replication slot retention

A logical replication slot pins WAL on the primary until the connector confirms it has consumed it. If the connector stalls, or is deleted while its slot remains, WAL accumulates without limit. Query the slots regularly:

  • SELECT slot_name, active, pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn)) AS retained FROM pg_replication_slots;
  • SELECT slot_name FROM pg_replication_slots WHERE NOT active;

Alert on retained WAL size, not only on whether a slot is inactive. Drop orphaned slots deliberately. Where your PostgreSQL version supports max_slot_wal_keep_size, set it so a stuck slot is invalidated before the disk fills. An invalidated slot means you will need a re-snapshot, which is a better outcome than a down primary.

Failure testing

Rehearse three failure modes in staging:

  • Database failover. Logical slots may not exist on the promoted replica. Without them, events committed around the switchover can be lost.
  • Forced re-snapshot. This re-emits existing outbox rows.
  • Deleted connector offsets. Depending on the snapshot mode, the connector either replays events (duplicates) or skips ahead (gaps).

Duplicates are expected, and your idempotent consumers should absorb them. Gaps are not. Verify each scenario end to end by reconciling outbox IDs against what consumers processed.

How it works, end to end

The earlier sections cover each piece separately. Here is the full path one event takes, from the application's commit to a consumer's side effect:

  1. Application write. The service opens a local transaction. It inserts the business row (orders) and one outbox row with an application-generated event id, aggregatetype, aggregateid, type and payload. Then it commits. No broker call happens in the request path.
  2. Log record. The commit lands in the database's transaction log: the WAL in PostgreSQL or the row-based binlog in MySQL. Rolled-back transactions never produce a decodable change, so a ghost event cannot leave the database.
  3. Capture. The Debezium connector runs as a task inside Kafka Connect. It streams the log through a replication slot and publication (PostgreSQL) or as a binlog client (MySQL). It filters to the outbox table and emits a change event for each insert, in commit order.
  4. Transform. The EventRouter SMT unwraps the change envelope. It picks the topic from aggregatetype, sets the Kafka key to aggregateid, uses payload as the message value, and copies the event id into an eventId header.
  5. Publish and checkpoint. Kafka Connect's producer writes the message to Kafka. Connect then flushes the source offset, which is the log position, periodically. On restart, the connector resumes from the last flushed position, so events published after that flush may be sent again.
  6. Consume. A consumer reads the topic. Because all events for one aggregate share a key and therefore a partition, they arrive in order. The consumer records the eventId in processed_events and applies its side effects in one database transaction, then commits its Kafka offset. Redelivered events hit the primary key and are skipped.
  7. Cleanup. The outbox row is deleted in the same transaction or pruned later. The router ignores the delete, and the slot advances as Connect confirms progress, so the database releases the WAL.

The pattern gets its guarantees from steps 1 and 2: the event exists if and only if the business change committed. Steps 5 and 6 turn at-least-once delivery into effectively-once processing.

Who should use it

The pattern fits teams that match most of the following:

  • Services that own a relational database and publish domain events. Typical cases are microservices emitting OrderPlaced or PaymentCaptured for other services, sagas and process managers that advance on events, and pipelines that keep search indexes, caches or read models in sync with a system of record.
  • Teams already running Kafka, or committed to it. Debezium runs on Kafka Connect, so an existing Connect cluster makes adoption much cheaper. The event router's routing and keying assume Kafka topics and partitions.
  • Teams that control their database configuration. You need to set wal_level=logical or row-based binlogs, create replication users, and manage replication slots. If your managed database or your DBA policy rules that out, this approach won't work.
  • Platform teams serving several services. A standard outbox table schema, connector template and consumer deduplication library can be reused across services. The operational cost of Connect is then paid once instead of per team.
  • Teams willing to build idempotent consumers. If downstream code cannot be made to tolerate duplicates, at-least-once delivery will cause bugs regardless of how the relay is built.

It is a poor fit for a single low-volume service with no Kafka footprint, for systems where eventual reconciliation is acceptable, and for teams without the on-call capacity to watch connector status and slot retention.

When to use it, when to avoid it, and the alternatives

Adopt the pattern when a service must emit an event for every committed state change, such as order placed or payment captured, and downstream systems cannot tolerate a missing or phantom event. Skip it for low-volume systems where a nightly reconciliation job is cheaper, or on managed databases that restrict logical replication, replication slots or the privileges Debezium needs.

Approach Reliability Latency Infrastructure Database access
Direct publish Loses or invents events on partial failure Lowest Broker only None beyond normal writes
Polling outbox relay At-least-once Bounded by poll interval Custom relay service Repeated queries load the primary
Debezium log-based CDC At-least-once, ordered per aggregate Near real time Kafka Connect, Debezium Replication privileges, slot management
Two-phase commit Atomic, if both sides support XA Higher, due to coordination locks Transaction coordinator XA support, which most brokers lack

Cost to the team

Debezium adds operational work. Someone has to run Kafka Connect, secure its credentials and replication user, and test upgrades of the connector and the database together. In return, you write and maintain no relay code. You also stop investigating events that were "sent but never committed" or "committed but never sent."

Illustrative estimate, assumed inputs: Suppose a custom polling relay takes three engineer-weeks to build and two days per quarter to maintain. Suppose lost-event incidents cost one engineer-week per quarter. Under those assumptions, Debezium pays off once your Connect upkeep stays below about seven days per quarter. If you already run Kafka Connect, that threshold is easy to meet. If you would be standing it up for a single service, a polling relay is often the cheaper choice.

How it saves engineering time

The time savings come from removing categories of work, not from making any single task faster:

  • No relay code to write or own. A hand-built polling relay needs a claim-and-lock scheme so that several instances don't publish the same rows. It also needs batching, retry and backoff logic, progress tracking and its own monitoring. With Debezium, those concerns are handled by the connector and Kafka Connect, and your part is a configuration file like the one in section 4.
  • Simpler application code. Publishing becomes an INSERT in a transaction the service already has. There is no broker client in the request path, no compensating logic for a failed publish after commit, and no retry loop that disappears when the process crashes.
  • Fewer incident investigations. The dual write failures in section 1 tend to show up as data mismatches that someone has to trace across services. Because events now derive from committed state, missing events and ghost events are no longer things to investigate. The remaining failure modes, which are connector failure, slot growth and redelivery, are known in advance and can be monitored and rehearsed (section 6).
  • Fewer reconciliation jobs. Batch jobs that compare database state against what downstream systems received exist to repair dual write gaps. Once the outbox closes those gaps, many of these jobs can be retired or reduced to a periodic audit.
  • Reuse across services. The outbox table schema, the connector template, the EventRouter settings and a shared idempotent-consumer helper carry over from one service to the next. Each additional service mostly needs a new table, publication, slot and connector name, rather than new code.
  • Easier debugging and replay. Events are plain rows before they're published and keyed messages afterward. When the connector or a consumer misbehaves, you can inspect the outbox row, the topic message and the processed_events record for the same event id.

The trade is operational time for Kafka Connect in exchange for development and incident time. The illustrative estimate above shows how to check whether that trade favors you, using your own inputs.

How OneEnv can help

Disclosure: OneEnv is the product behind this blog.

OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.

Applied to transactional outbox pattern, this is where it helps:

Saves time

  • Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
  • One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
  • Changing a shared variable is one edit and one review instead of editing every service's .env by hand.

Saves cost

  • Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
  • Less duplicated configuration to maintain: one shared definition inherited by many services.
  • A wrong or stale value is caught at review time by the services it affects, not by a failed deploy.
  • OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.

These are qualitative benefits, not measured results — how much you save depends on how many services share the value.

Want to try it? Start at oneenv.io.