Architecture
ArchitectureIntermediate

The Outbox Pattern: Reliable Events Without Distributed Transactions

Saving data and publishing an event are two writes to two systems, and no retry can make them atomic. The transactional outbox writes the event in the same database transaction and ships it afterwards. Schema, polling vs CDC with Debezium, idempotent consumers, ordering and operations.

Cover image: The Outbox Pattern: Reliable Events Without Distributed Transactions
Contents

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 Outbox Pattern: commit the event with the data, ship it afterwards.

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.

The dual-write problem: commit then crash loses the event, publish then roll back sends a phantom event, and publishing inside the transaction holds locks and ends in an unknown state on timeout.
Each ordering fails differently, and retries cannot fix any of them.
  1. 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.
  2. 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.
  3. 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:

  1. Write the event as a row. An outbox table lives in the same database as your business data.
  2. 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.
  3. Relay it afterwards. A separate process reads committed outbox rows and publishes them to the broker, retrying until the broker acknowledges.
How the outbox works: the orders row and the outbox row are written in one database transaction, a relay reads committed outbox rows, publishes them to the broker keyed by aggregate ID, and consumers deduplicate by event ID.
The event shares the fate of the data. Delivery becomes at-least-once, and consumers deduplicate.

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.

outbox.sql
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

place-order.sql
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.

Polling publisher vs transaction log tailing: polling queries the outbox table with SKIP LOCKED and marks rows published; log tailing reads the WAL or binlog with Debezium and its EventRouter and publishes to a topic keyed by aggregate ID.
Start with polling. Move to CDC when latency, load or ordering demands it.

Option A: polling publisher

A loop in your own service. Claim a batch, publish it, mark it done, all in a short transaction:

relay-batch.sql
BEGIN;
SELECT id, aggregatetype, aggregateid, type, payload
FROM outbox
WHERE published_at IS NULL
ORDER BY created_at
LIMIT 100
FOR 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:

outbox-connector.properties
connector.class=io.debezium.connector.postgresql.PostgresConnector
plugin.name=pgoutput
table.include.list=public.outbox
transforms=outbox
transforms.outbox.type=io.debezium.transforms.outbox.EventRouter
transforms.outbox.table.expand.json.payload=true
value.converter=org.apache.kafka.connect.json.JsonConverter

With 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.

consume-order-placed.sql
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 commit

At-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:

Outbox implementations in Java, .NET, Go and MongoDB
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?

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

Frequently asked questions

What is the transactional outbox pattern?

It is a way to update a database and publish an event reliably without a distributed transaction. The service writes the business change and an event row into an outbox table inside the same local database transaction. A separate relay process reads committed outbox rows and publishes them to a message broker such as Kafka, Pub/Sub or RabbitMQ, retrying until the broker acknowledges. Because both writes share one transaction, the event exists if and only if the business change committed.

Why not just publish to the broker after committing?

Because the process can crash, be killed or lose the network between the commit and the publish. The data is saved but the event is gone, and nothing will ever retry it, because the code that would retry is the code that died. Publishing before the commit has the mirror-image problem: if the transaction then rolls back, consumers receive an event about data that never existed. Calling the broker inside the transaction does not help either, because the broker is not part of the database transaction and a timeout leaves you unable to tell whether the message went out.

Does the outbox pattern give exactly-once delivery?

No. It guarantees at-least-once delivery. The relay can publish a message and crash before it records that the row was sent, so after a restart it publishes the same event again. The fix is on the consumer side: every event carries a unique ID, and the consumer records processed IDs, ideally in the same transaction as its own side effect, so a duplicate is detected and skipped. At-least-once delivery plus idempotent consumers gives you effectively-once processing.

What is the inbox pattern?

The inbox pattern is the consumer-side counterpart of the outbox. Before a consumer applies an event, it inserts the event ID into an inbox (processed events) table in the same transaction as its own side effect. If the ID is already there, the event is a duplicate and is skipped. Because the outbox delivers at least once, an inbox, or another form of idempotent handling, is what turns repeated delivery into a single effect.

How does the outbox pattern relate to the saga pattern?

A saga coordinates a business process across services as a sequence of local transactions, each followed by an event or command that triggers the next step. Every step therefore has the same dual-write problem: commit locally, then tell the next service. The outbox is how each saga step publishes that message reliably, so sagas and the outbox are usually used together rather than as alternatives.

Should I use polling or CDC with Debezium for the outbox relay?

Start with a polling publisher unless you already run Kafka Connect. Polling is your own code, needs no extra infrastructure and is fast enough when an interval of a few hundred milliseconds is acceptable. Move to log tailing with change data capture, for example Debezium with its Outbox Event Router, when you need lower latency, less query load on the database or strict commit ordering at high volume. CDC adds a connector to operate, and in PostgreSQL a replication slot that retains WAL whenever the connector stops.

How do I clean up the outbox table?

With a polling relay, either delete rows after they are published or keep them for a short audit window and delete them in batches; for high volume, partition the table by time and drop old partitions, which is far cheaper than large DELETE statements. With Debezium you can insert the outbox row and delete it in the same transaction: the insert is still in the transaction log, so the connector publishes it, and the Outbox Event Router ignores the delete. The table then stays empty.

What are the alternatives to the outbox pattern?

Two-phase commit across the database and the broker is the textbook answer, but most brokers, including Kafka, do not take part in XA transactions, and it couples availability of both systems. Event sourcing makes the event store the source of truth, so there is no second write, but it changes your whole persistence model. Capturing changes directly from business tables with CDC also avoids a dual write, but it publishes your internal schema as the contract. The outbox keeps a deliberate, versioned event contract with only a local transaction.

From the community

Discussion on Mastodon & Bluesky

Replies to this article from the open web, pulled in live. No ads, no tracking scripts.

Loading replies …
Reply on MastodonReply on Bluesky

Reply there — it shows up here automatically.

ENDE