Every service that owns data eventually has to tell the rest of the system that something happened. An order is placed, so billing, shipping and email need to know. The obvious code is two lines: save the order, then publish OrderPlaced.
Those two lines are the most common source of silent data loss in event-driven systems. They write to two different systems, and nothing makes the pair atomic. The transactional outbox pattern is the standard fix: write the event into your own database in the same transaction as the data, and ship it afterwards. This guide covers the failure it prevents, the schema, both ways to relay events, and the parts most implementations get wrong: duplicates, ordering and cleanup.

The dual-write problem
Saving to a database and publishing to a message broker is a dual write. There are only three ways to order it, and all three break.

- Commit, then publish. The process crashes, is redeployed or loses the network after the commit. The order exists; the event does not. Billing never charges, shipping never ships, and no error is logged anywhere, because the code that would have retried is the code that died.
- Publish, then commit. The event goes out, then the transaction fails on a constraint or a deadlock and rolls back. Consumers now act on an order that never existed: a phantom event.
- Publish inside the transaction. The broker is not part of your database transaction, so this only looks safer. Row locks stay held while you wait on the network, and when the call times out you cannot tell whether the message went out.
The textbook answer is a distributed transaction (two-phase commit, XA) spanning both systems. In practice most brokers do not take part in XA, and 2PC couples the availability of both systems to each other. Kafka’s own transactions cover writes to Kafka topics, not to your database. Kafka has accepted an opt-in way for a producer to join an externally coordinated two-phase commit (KIP-939), but it is not XA either: your application becomes the coordinator and has to store and recover transaction state itself.
The way out is to stop having two writes.
What the transactional outbox pattern is
The transactional outbox pattern stores outgoing events in an outbox table, in the same database transaction as the business change, and publishes them afterwards with a separate relay. You get reliable, at-least-once event publishing between microservices without a distributed transaction.
The pattern, catalogued by Chris Richardson on microservices.io, turns two writes into one:
- Write the event as a row. An
outboxtable lives in the same database as your business data. - Use the same transaction. The business change and the outbox insert commit or roll back together. The event exists if and only if the change committed.
- Relay it afterwards. A separate process reads committed outbox rows and publishes them to the broker, retrying until the broker acknowledges.

The database transaction you already trust is now the only atomic step. Everything after the commit is a retry loop, and retry loops are easy to make reliable.
It is the same building block that sits at the start of a reliable webhook delivery pipeline and behind sending a million notifications without falling over.
Step 1: the outbox table
Use the column names Debezium expects by default. They cost nothing and keep the door open to change data capture later.
CREATE TABLE outbox ( id uuid PRIMARY KEY, aggregatetype varchar(255) NOT NULL, -- routes to a topic, e.g. "order" aggregateid varchar(255) NOT NULL, -- message key, keeps per-aggregate order type varchar(255) NOT NULL, -- "OrderPlaced" payload jsonb NOT NULL, created_at timestamptz NOT NULL DEFAULT now(), published_at timestamptz -- only used by a polling relay);
-- The polling relay only ever looks at unpublished rows.CREATE INDEX outbox_unpublished ON outbox (created_at) WHERE published_at IS NULL;The id is the event’s identity for its whole life. Consumers use it to detect duplicates, so generate it in the application, not in the relay.
Step 2: write the event in the same transaction
BEGIN;INSERT INTO orders (id, customer_id, total) VALUES (42, 7, 99.00);
INSERT INTO outbox (id, aggregatetype, aggregateid, type, payload)VALUES (gen_random_uuid(), 'order', '42', 'OrderPlaced', '{"eventVersion": 1, "orderId": 42, "customerId": 7, "total": 99.00}');COMMIT;Two details matter more than they look:
- The payload is a contract, not a row dump. Write the event consumers need, with an explicit version, not
SELECT *of the orders table. Your table will change; the event schema has to evolve compatibly. - Keep the transaction short. The outbox adds one insert. It must not add a network call.
Step 3: relay the rows
There are two ways to get committed rows out of the table. Richardson names them polling publisher and transaction log tailing.

Option A: polling publisher
A loop in your own service. Claim a batch, publish it, mark it done, all in a short transaction:
BEGIN;SELECT id, aggregatetype, aggregateid, type, payloadFROM outboxWHERE published_at IS NULLORDER BY created_atLIMIT 100FOR UPDATE SKIP LOCKED;
-- publish each row to the broker and wait for the ack, then:UPDATE outbox SET published_at = now() WHERE id = ANY($published_ids);COMMIT;FOR UPDATE SKIP LOCKED (PostgreSQL 9.5+, MySQL 8.0+) lets several relay instances run without fighting over the same rows. It also introduces the two classic bugs of polling relays:
The second bug is ordering. With several relays and SKIP LOCKED, two events for the same order can be published by different instances in either order. If consumers care, and they usually do, give each aggregate a single owner: one relay, or relays that each claim a fixed range of hash(aggregateid).
Polling adds latency of up to one interval. In PostgreSQL you can cut it without polling harder: issue NOTIFY outbox in the writing transaction and have the relay LISTEN. Notifications are only delivered when the transaction commits, so the relay wakes up exactly when there is something to read, and the interval becomes a safety net.
Option B: transaction log tailing (CDC)
Instead of querying the table, read the database’s own transaction log: the WAL in PostgreSQL, the binlog in MySQL. Inserts arrive in commit order, with no polling queries at all. The common tool is Debezium with its Outbox Event Router:
connector.class=io.debezium.connector.postgresql.PostgresConnectorplugin.name=pgoutputtable.include.list=public.outboxtransforms=outboxtransforms.outbox.type=io.debezium.transforms.outbox.EventRoutertransforms.outbox.table.expand.json.payload=truevalue.converter=org.apache.kafka.connect.json.JsonConverterWith the default columns from Step 1, the router publishes each row to the topic outbox.event.<aggregatetype>, uses aggregateid as the message key, so all events of one aggregate land in one partition in order, and puts the event id into a message header for deduplication.
Two things make log tailing attractive at scale:
- The table can stay empty. Insert the outbox row and delete it in the same transaction. The insert is still in the log, so Debezium publishes it, and the router ignores deletes. No cleanup job, no bloat.
- No query load. The connector reads the log the database writes anyway.
And one thing to respect: in PostgreSQL, CDC uses a replication slot, and the database keeps WAL for it until the connector confirms it. A connector that is down for a weekend can fill the disk. Alert on slot lag, and cap it with max_slot_wal_keep_size (PostgreSQL 13+).
At-least-once: make consumers idempotent
The outbox guarantees that an event is never lost. It does not guarantee that it arrives once. The relay can publish a batch and crash before it records published_at. After a restart it publishes the same events again. A broker redelivery or a consumer crash before its ack does the same.
So every consumer must deduplicate, and the robust way is an inbox: record the event ID in the same transaction as the side effect.
BEGIN;INSERT INTO processed_events (event_id) VALUES ($event_id)ON CONFLICT (event_id) DO NOTHING;-- 0 rows inserted: duplicate, commit and acknowledge without doing anything.
INSERT INTO invoices (order_id, amount) VALUES (42, 99.00);COMMIT;-- acknowledge the message only after the commitAt-least-once delivery plus idempotent consumers is what people mean by “exactly-once” in practice. The same technique protects HTTP APIs; the details are in idempotency in practice.
Ordering: what you can and cannot promise
- Per aggregate: yes. Key every message by
aggregateid. Kafka keeps order within a partition, and Google Pub/Sub has ordering keys for the same purpose. All events for order 42 arrive in the order they were committed, as long as a single relay owns that key. - Globally: no. Events for order 42 and order 17 can arrive in any relative order. Design consumers so that is fine.
- Defend anyway. Put a version or sequence number per aggregate in the payload. A consumer that sees version 5 after version 6 can ignore it instead of overwriting newer state.
Operating the outbox
Cleanup. A polling relay leaves published rows behind. Delete them in batches after a short audit window, or for high volume partition the table by day and drop old partitions, which costs nothing compared with large DELETE statements and the vacuum work they cause. With CDC, delete in the same transaction as described above.
Monitoring. One metric tells you almost everything: the age of the oldest unpublished event. With polling that is now() - min(created_at) WHERE published_at IS NULL; with CDC it is replication lag. Alert when it exceeds a few minutes; it catches a dead relay, a broker outage and a poison event with the same rule.
Poison events. An event the broker rejects (too large, invalid schema) blocks the queue for its key. Count attempts per row, and after a limit move it to a dead-letter table and alert, instead of retrying forever.
Tracing. Store the trace context in the outbox row and restore it in the relay, so a trace follows the request from the API call through the broker into every consumer. Debezium’s router supports this with a tracing column.
Outbox implementations in Java, .NET, Go and MongoDB
You rarely need to write the relay from scratch. The pattern ships in the frameworks most teams already use:
| Stack | Ready-made outbox | Note |
|---|---|---|
| Java, Spring | Spring Modulith Event Publication Registry | Its own docs say the native externalization lacks advanced outbox features; 2.1 adds Namastack Outbox and JobRunr as backends |
| Java | Eventuate Tram | From Chris Richardson, the author of the pattern catalogue |
| .NET (C#) | MassTransit Transactional Outbox, NServiceBus Outbox, Wolverine, CAP | MassTransit and NServiceBus also deduplicate on the consumer side |
| Go | Watermill Forwarder | Publishes into an SQL table in your transaction, forwards to the real broker |
| Any language | Debezium (Kafka Connect or Debezium Server) | Debezium Server sends to Google Pub/Sub, Amazon Kinesis and others without Kafka |
MongoDB supports multi-document transactions on replica sets, so the outbox works the same way: write the order and the outbox document in one session transaction. Debezium has a dedicated MongoDB outbox event router, because the relational one does not work with the MongoDB connector.
Cloud databases often have a log you can tail instead of polling: DynamoDB Streams after a TransactWriteItems that writes the item and the event together, or Spanner change streams on an outbox table.
Outbox or something else?
| Approach | Atomic with your data | Extra infrastructure | Trade-off |
|---|---|---|---|
| Commit, then publish | No | None | Loses events on crash |
| Two-phase commit (XA) | Yes | XA-capable broker | Most brokers don’t support it; couples availability |
| Transactional outbox | Yes | A relay | At-least-once, consumers must dedupe |
| CDC on business tables | Yes | CDC pipeline | Your internal schema becomes the public contract |
| Event sourcing | Yes, the event is the data | Event store | Changes your whole persistence model |
The outbox is the option that keeps a deliberate event contract while needing nothing more exotic than a local transaction.
When you don’t need an outbox
- Nobody outside the database needs to know. If the follow-up work is a job in a queue table in the same database, the job row already commits atomically with your data. You have an outbox without the name.
- You already do event sourcing. The append to the event store is the state change; publish from the store.
- Losing an event is genuinely fine. Analytics pings and best-effort cache warming can tolerate the rare loss. Business facts like orders, payments and permissions cannot.
The pattern also fits naturally into how a microservices architecture works: services own their data, and asynchronous events are how the rest of the system learns about changes without reading another service’s database.
The bottom line
The bug the outbox fixes is invisible in testing and inevitable in production: a crash between “saved” and “published”. The fix is to stop treating the event as a second write. Commit it with the data, in the transaction you already trust, and let a relay deliver it with retries.
Start simple: an outbox table with Debezium-compatible columns, a polling relay with SKIP LOCKED that selects unpublished rows rather than reading past the last ID, consumers that deduplicate by event ID, and one alert on the age of the oldest unpublished event. Move to log tailing when latency or load asks for it. Your schema is already ready.
Read next: Webhooks explained: signatures, retries and idempotency · Send a million notifications without falling over






From the community
Discussion on Mastodon & Bluesky
Replies to this article from the open web, pulled in live. No ads, no tracking scripts.
Reply there — it shows up here automatically.