What Is the Outbox Pattern?

What Is the Outbox Pattern?

What Is the Outbox Pattern?

A complete, plain-language guide to the transactional outbox pattern — how it stops microservices from silently losing events, why the “obvious” fixes are traps, and how companies like Uber, Shopify, and Netflix rely on it every single day. Covering the dual-write problem, the atomic-commit trick, polling vs. change-data-capture (CDC), Debezium’s Outbox Event Router, idempotent consumers, at-least-once delivery, ordering by aggregate ID, and how the pattern fits alongside Saga, CQRS, and event sourcing.

01 · Introduction: What Is the Outbox Pattern?

Imagine you run a small toy shop. Every time you sell a toy, you do two things: you write the sale down in your ledger, and you shout across the street to your friend at the delivery shop so they know to pick up the toy and take it to the customer. Most days this works fine. But one day, right after you write the sale in your ledger, you get distracted by a customer and forget to shout across the street. The sale is recorded. The toy never gets delivered. The customer waits and waits, and nobody even knows something went wrong.

This tiny, everyday mistake is exactly the problem the outbox pattern was invented to solve inside computer systems — except instead of toys, it is orders, payments, sign-ups, and shipments, and instead of shouting across a street, it is one computer program sending a message to another one over a network.

In plain terms, the outbox pattern is a way of guaranteeing that when a service saves something important to its database and needs to tell other services about it, both things happen together — reliably, every single time — even if the network fails, the message broker is down, or the server crashes at the worst possible moment.

It does this with a surprisingly simple trick: instead of trying to write to the database and send a message to two different systems at the same time (which is risky), the service writes both the business data and a record of the message into the same database, in the same transaction. Because a single database transaction is atomic — meaning it either fully succeeds or fully fails, with no in-between — this guarantees that the sale and the “please tell someone” note are never separated. A second background process then reads that note and delivers the message, retrying patiently until it succeeds.

Real-life analogy. Picture a restaurant kitchen. When a waiter takes an order, they don’t shout it to the chef and hope the chef is listening. They write it on a ticket and clip it onto a spinning order wheel. The chef reads tickets off the wheel whenever they’re ready. Even if the chef steps away for two minutes, the ticket is still there, waiting. The order is never lost just because the chef didn’t hear a shout at the exact right moment. The order wheel is the outbox. The chef reading and cooking each ticket is the message relay.

This pattern is one of the most widely used building blocks in modern microservices architecture — systems built from many small, independent services that talk to each other, usually through message brokers like Apache Kafka, RabbitMQ, Amazon SQS, or Azure Service Bus. Wherever a service needs to update its own data and reliably announce that update to the rest of the system, the outbox pattern tends to show up.

In one sentence. The outbox pattern makes “save this data” and “tell everyone about it” a single, unbreakable step, by writing both to the same database table and letting a background worker handle the actual delivery.

02 · A Short History: Where Did This Pattern Come From?

The idea behind the outbox pattern is not brand new. Long before “microservices” was a popular word, banking systems and telecom billing systems faced the exact same headache: how do you update an account balance and reliably tell a downstream system about it, without ever losing the message or duplicating it?

In the 1990s and early 2000s, the standard answer was a technology called distributed transactions, often implemented through a protocol called XA and a coordination technique called the two-phase commit (2PC). The idea was to make the database write and the message send part of one giant transaction that spanned two completely different systems. It technically worked, but it was slow, fragile, and it required every piece of infrastructure involved — the database, the message queue, the application server — to support the same complicated coordination protocol. Many modern databases and brokers, including most of the tools teams use today such as Kafka, PostgreSQL in its common configuration, and MongoDB, either don’t support XA well or don’t support it at all.

As systems moved toward service-oriented architecture in the mid-2000s and then toward fine-grained microservices in the 2010s, this problem became far more common and far more painful. A single monolithic application talking to one database rarely needed distributed transactions. But once a system was broken into dozens of independent services, each with its own database, “update my data and notify everyone” became a daily occurrence, not a rare edge case.

The name “outbox” itself borrows from the humble idea of a physical office outbox — a tray on your desk where you place letters that are ready to be mailed. You don’t personally walk each letter to the post office the instant you finish writing it. You place it in the outbox, and the mail clerk collects everything in the tray on their normal rounds. If the mail clerk is late one day, your letters are not lost — they are still sitting safely in the tray.

The pattern was popularized in its modern microservices form through the writing of software architect Chris Richardson, particularly in his book Microservices Patterns and on his companion site microservices.io, where it was formally catalogued as the Transactional Outbox pattern alongside related patterns like Saga and CQRS. Around the same time, the open-source Debezium project — a change-data-capture (CDC) tool originally built at Red Hat — added first-class support for reading outbox tables and automatically routing their contents to Kafka topics, which turned the pattern from a manual, hand-rolled trick into something teams could adopt with well-tested, off-the-shelf tooling.

Fig 1 · From XA/2PC to the modern outbox pattern 1990s XA + 2PC banking / telecom mid-2000s SOA emerges lighter patterns 2010s Microservices go mainstream 2018-2019 Debezium adds Outbox Event Router Today Default building block · like a load balancer heavy coordination “dual write” problem named standard infrastructure 3 decades · from distributed transactions to a single database transaction + a patient retry loop
Fig 1 — Three decades of evolution, from heavy 2PC coordination protocols to a single database transaction plus a patient retry loop.

Key milestones

  1. 1990s — Distributed Transactions & 2PC. Banking and telecom systems used two-phase commit and XA transactions to keep databases and message queues in sync — powerful, but slow and operationally heavy.
  2. Mid-2000s — Service-Oriented Architecture. As systems split into services, the “update data and notify others” problem became more frequent, and lighter-weight patterns started to emerge in enterprise integration circles.
  3. 2010s — Microservices Go Mainstream. Chris Richardson catalogues the Transactional Outbox pattern formally as part of the broader microservices patterns language, alongside Saga and CQRS.
  4. 2018-2019 — Debezium Adds Outbox Support. The Debezium project ships an “Outbox Event Router” transform, letting teams stream outbox rows straight into Kafka using change data capture, without hand-writing a polling worker.
  5. Today — A Default Building Block. The outbox pattern is now considered a standard, almost assumed, piece of infrastructure in any serious event-driven microservices system — as ordinary as using a load balancer.

03 · The Problem & Motivation: The Dual-Write Problem

To understand why the outbox pattern exists, you first need to understand the exact problem it fixes. That problem has a name: the dual-write problem.

Picture a simple online store. A customer places an order. The order service needs to do two separate things:

  1. Save the new order into its own database (so the order history shows up correctly).
  2. Publish an OrderCreated event to a message broker, so that the inventory service can reduce stock and the shipping service can start preparing a delivery.

The tempting, “obvious” way to write this code looks something like: save the order to the database, then send the message to the broker. That looks completely reasonable. The trouble is that these are two entirely separate systems — a database and a message broker — and there is no way to guarantee that both operations succeed or fail together. Between step one and step two, almost anything can go wrong.

1. Database succeeds, broker fails

The order is saved, but the network to Kafka or RabbitMQ times out. The order exists, but no other service ever finds out. Inventory is never reduced. Shipping never starts.

2. Broker succeeds, database fails

The event is published, but the database write then fails or rolls back — perhaps a constraint violation. Now other services believe an order exists that was never actually saved. This is a “phantom event.”

3. The process crashes in between

The database commit succeeds, but the server crashes — perhaps due to a deployment, an out-of-memory error, or a hardware fault — before it can even attempt to send the message.

4. The message broker is simply down

Message brokers, like every other system, have downtime. If Kafka is unreachable for five minutes during a rolling upgrade, every order placed in that window risks being silently dropped from the event stream.

Fig 4 · Four ways naive dual-writes fail 1) DB ✓ broker ✗ Order saved. Kafka times out. Order exists. Nobody notified. Inventory not reduced. SILENT DATA LOSS no crash · no error · no log 2) broker ✓ DB ✗ Event fired. DB rollback. Consumers see an order that was never saved. PHANTOM EVENTS state disagrees with reality 3) Process crashes in between DB commit ✓. Server crashes before broker call is made. Deploy / OOM / hardware. EVENT NEVER FIRED restart forgets to send it 4) Broker itself is down 5 minutes of Kafka downtime during a rolling upgrade. Every order in that window at risk. SILENT BATCH LOSS an entire time window drops
Fig 2 — The four canonical ways a naive dual write silently fails — each individually rare, all disturbingly common at scale.

Each of these scenarios is individually rare. But across millions of orders per day, “rare” adds up quickly to a steady trickle of silently lost or duplicated events — the worst kind of bug, because nothing crashes, no error is logged, and the system looks perfectly healthy while quietly losing data.

If it’s not in your database and your message queue at the same time, it’s not really “done” — it’s just partially done and hoping nobody notices.

This is called a dual write because the application is writing to two independent, unrelated systems and treating that as if it were one atomic operation, when in reality there is no shared guarantee tying the two writes together.

04 · Why the “Easy” Fixes Don’t Really Work

When engineers first run into the dual-write problem, they usually try one of a few quick fixes. It helps to understand why each one falls short, because it explains exactly what the outbox pattern is designed to avoid.

A. Publish first, then save

Swapping the order doesn’t remove the problem, it just moves it. Now you risk phantom events: the message goes out, but the database save fails afterward.

B. Retry in application code

Wrapping the broker call in a retry loop helps with brief network blips, but does nothing if the process crashes mid-retry, and it can’t tell the difference between “broker is slow” and “broker is permanently unreachable.”

C. Two-phase commit (2PC / XA)

This is the “textbook correct” distributed transaction approach, but most modern databases and brokers (Kafka included) don’t support XA well, it’s slow under load, and it introduces a single point of coordination failure.

D. “Best effort” and hope

Some teams simply accept the occasional lost message as a cost of doing business. This works until it doesn’t — a lost payment confirmation or a lost inventory update is rarely an acceptable loss.

Common misconception. People often assume that message brokers themselves guarantee delivery. They don’t — a broker can only guarantee delivery of a message it actually received. The dual-write problem happens before the message even reaches the broker, so no amount of broker-side reliability can fix it.

None of these workarounds address the root cause: two independent systems, no shared transaction, no way to guarantee both succeed together. The outbox pattern solves this not by making two systems agree, but by removing the need for them to agree at all — it turns a hard distributed-systems problem into an easy local-database problem.

05 · Core Concepts & Terminology

Before going further, let’s build a shared vocabulary. Each of these terms will come up again and again through the rest of this guide.

Outbox table

A regular database table, living in the same database as your business data, used purely to hold “events waiting to be sent.” Nothing fancy — just rows, like any other table.

Message relay

A background process that reads rows from the outbox table and actually delivers them to the message broker, then marks or removes them once delivery is confirmed.

Change data capture (CDC)

A technique for watching a database’s internal transaction log — the same log the database uses for crash recovery — and reacting to every row that gets inserted or changed, without querying the table directly.

Aggregate

A domain-driven design term for a cluster of related data treated as one unit, like an “Order” with its line items. Outbox rows are usually tagged with an aggregate type and ID so consumers know what the event is about.

Idempotency

The property that doing something more than once has the same effect as doing it once. Outbox consumers must be idempotent, because the pattern guarantees a message is delivered at least once — not exactly once.

At-least-once delivery

A delivery guarantee meaning a message will arrive one or more times, never zero times. This is the guarantee the outbox pattern provides, and it’s why idempotent consumers matter so much.

Simple analogy. Think of the outbox table as a mailbox at the end of your driveway, and the message relay as the postal worker who visits on a route. You don’t need to personally deliver every letter to its destination — you just need to trust that once a letter is safely in the mailbox, someone reliable will pick it up. The letter being “in the mailbox” and the letter being “written” happen at the same moment, which is exactly what a database transaction guarantees.

Database transaction (ACID)

A group of operations that the database treats as a single unit. Either every operation inside the transaction succeeds and is saved permanently (a commit), or if anything goes wrong, none of them are saved at all (a rollback). This all-or-nothing behavior, summarized by the acronym ACID (Atomicity, Consistency, Isolation, Durability), is the entire foundation the outbox pattern is built on. Databases have been perfecting this guarantee for decades — the outbox pattern simply borrows it instead of trying to invent a new one across two different systems.

06 · Architecture & Components

The outbox pattern has surprisingly few moving parts. Once you can name each piece, the whole picture becomes much easier to hold in your head.

Fig 2 · The full outbox architecture Order Service orders table business data outbox_events table id + aggregate + payload same DB transaction ACID commit both or neither poll / CDC Message Relay polling worker OR Debezium (CDC) retry with backoff Message Broker Kafka / RabbitMQ / SQS Azure SB / Pub/Sub key = aggregate_id Inventory Service idempotent consumer Shipping Service idempotent consumer one atomic write → patient relay → broker fan-out → idempotent consumers
Fig 3 — The order service writes to its orders table and outbox table in one transaction. A separate relay reads the outbox and forwards events to the broker, which fans them out to downstream services.

Let’s name each piece in the picture above:

1. The service & its database

The originating microservice, along with its own private database. Nobody else is allowed to touch this database directly — that’s a core microservices rule, and the outbox pattern respects it fully.

2. The business table

Wherever the actual domain data lives — an orders table, a payments table, a users table. This is what the service exists to manage.

3. The outbox table

A small, purpose-built table storing pending events: typically an ID, an aggregate type, an aggregate ID, an event type, a JSON payload, and a timestamp.

4. The relay / CDC connector

The bridge between the database and the broker. Either a small polling script the team writes themselves, or a managed tool like Debezium that tails the database’s transaction log.

5. The message broker

Kafka, RabbitMQ, Amazon SQS/SNS, Azure Service Bus, or Google Pub/Sub — whatever technology carries messages between services.

6. Downstream consumers

Other microservices subscribed to the relevant topics or queues, reacting to events as they arrive — updating their own local data in response.

07 · Internal Working: How It Actually Happens

Let’s walk through exactly what happens inside the order service, step by step, when a customer places an order.

First, the service opens a database transaction. Inside that single transaction, it performs two inserts: one into the orders table with the new order’s details, and one into the outbox table describing an OrderCreated event, including a JSON payload with the order ID, customer ID, and item list. Both statements are sent to the database as part of the same transaction block.

Fig 6 · What placeOrder() actually does inside one transaction @Transactional — ONE database transaction Step 1 Save business entity orderRepository.save(order) → orders table business row + Step 2 Save outbox message outboxRepository.save(msg) → outbox_events table event row (JSON) = Step 3 COMMIT both saved OR both rolled back no broker call inside the transaction · that’s what makes it safe
Fig 4 — Inside placeOrder(): two inserts and one commit — and, crucially, no broker call anywhere inside the transaction.

Here’s a minimal Java example using Spring’s @Transactional annotation, which tells the framework to wrap everything inside the method in a single database transaction:

@Transactional
public void placeOrder(OrderRequest request) {

    // 1. Save the business entity
    Order order = new Order(request);
    orderRepository.save(order);

    // 2. Build the event payload
    String payload = objectMapper.writeValueAsString(
        new OrderCreatedEvent(order.getId(), order.getItems())
    );

    // 3. Save the outbox row - same transaction, same commit
    OutboxMessage message = new OutboxMessage(
        "Order",             // aggregateType
        order.getId(),       // aggregateId
        "OrderCreated",      // eventType
        payload
    );
    outboxRepository.save(message);

    // Both rows commit together, or neither does.
}

Notice what is not in this method: there is no call to Kafka, no call to RabbitMQ, no network call to any other service at all. The method’s entire job is to write two rows to one database and let the database’s transaction guarantee do the heavy lifting. If the transaction commits, both rows exist. If anything fails — a validation error, a constraint violation, a crash — the entire transaction rolls back and neither row exists. There is no possible in-between state.

A separate, independent process — the message relay — is responsible for actually getting that outbox row to the broker. It might poll the table every few hundred milliseconds looking for unprocessed rows, or it might use change data capture to react the instant a new row is written to the database’s transaction log. Either way, this relay process runs continuously and retries automatically if the broker is temporarily unavailable, because unlike the original request, it isn’t blocking a customer waiting for a response — it can take its time and be patient.

Why this works. The trick isn’t clever code — it’s borrowing a guarantee the database already provides for free. A relational database has spent decades perfecting atomic, all-or-nothing writes. The outbox pattern just asks it to protect one more row alongside the data you were already saving.

It helps to notice what this design deliberately gives up in exchange for that safety. The order service no longer knows, at the moment it responds to the customer, whether the OrderCreated event has actually reached Kafka, RabbitMQ, or any downstream system. It only knows that the event is durably recorded and guaranteed to be delivered eventually. For most businesses this trade is an easy one to accept — a few hundred milliseconds of extra delivery latency is a small price for never silently losing an order confirmation. But it does mean the pattern is a poor fit for situations where a caller genuinely needs to know, synchronously, that a downstream system has already reacted before the response returns — for that narrow class of problem, a direct synchronous API call with its own retry and timeout handling is usually the more honest tool.

08 · Data Flow & Lifecycle

Every outbox event moves through the same predictable lifecycle, from the moment it’s created to the moment it’s safely consumed elsewhere.

Fig 5 · The 8-step outbox event lifecycle 1) Request arrives customer places order 2) Txn begins business + outbox scope 3) Two inserts, one commit both rows or neither 4) Response returns caller does NOT wait on broker 5) Relay picks up row poll or CDC 6) Publish to broker Kafka topic · keyed by aggregate 7) Row marked processed deleted or flagged 8) Consumer reacts idempotent processing customer sees success at step 4 · broker delivery decoupled from request/response
Fig 5 — The 8-step lifecycle of an outbox event, from the customer’s click to the downstream consumer’s idempotent update.
  1. Request arrives. A customer action (placing an order, updating a profile, cancelling a subscription) triggers a service method.
  2. Transaction begins. The service opens a database transaction covering both the business write and the outbox write.
  3. Two inserts, one commit. The business row and the outbox row are inserted. The transaction commits — or, if anything fails, both are rolled back together.
  4. Response returns to the caller. The original HTTP request or API call returns success immediately after the commit — it does not wait for the message to actually reach Kafka or RabbitMQ.
  5. Relay picks up the row. The polling worker or CDC connector detects the new outbox row, moments after the commit.
  6. Message published to the broker. The relay sends the event to the appropriate topic or queue, typically keyed by aggregate ID for ordering.
  7. Row marked processed (or deleted). Once the broker confirms receipt, the outbox row is deleted or flagged as sent, so it isn’t re-sent by a future poll.
  8. Downstream consumers react. Other services consume the event from the broker and update their own local state — idempotently, in case the same event arrives more than once.

Notice step 4: the caller gets a response the instant the local database transaction commits. This is one of the most valuable side effects of the pattern — the customer-facing request stays fast, because it never waits on the message broker, which might be slow, overloaded, or briefly unreachable. Delivery is decoupled from the request/response cycle entirely.

09 · Two Ways to Build the Relay: Polling vs. CDC

There are two common strategies for the message relay, and understanding the difference matters when deciding how to implement the pattern in your own system.

Fig 3 · Two ways to build the relay Polling Publisher simple · no extra infra outbox_events table SELECT WHERE every 200-500ms Polling Worker scheduled job ✓ No new infrastructure ✓ Easy to reason about ✗ Polling delay (200-500ms) ✗ Repeated read load ✗ Full scans as table grows START HERE · small to medium systems Change Data Capture (Debezium) near-real-time · zero query load DB WAL / binlog transaction log tail log no polling Debezium Kafka Connect ✓ Near real-time (ms) ✓ Zero added query load ✗ Extra infra to operate ✗ Logical replication setup ✗ Heavier ops burden GRADUATE HERE · when volume justifies it
Fig 6 — Polling and CDC solve the same problem with very different trade-offs. Most teams start with polling and graduate to CDC only when volume justifies the extra infrastructure.

Polling Publisher

  • A worker queries the outbox table on a fixed interval (e.g. every 200-500ms) for unprocessed rows.
  • Simple to build — just a scheduled job with a SELECT ... WHERE processed = false query.
  • No extra infrastructure needed beyond your existing database and application.
  • Good starting point for small to medium systems, or when adding CDC infrastructure isn’t justified yet.

CDC / Log-Tailing (e.g. Debezium)

  • Reads the database’s transaction log directly (PostgreSQL’s WAL, MySQL’s binlog) instead of querying the table.
  • Near real-time delivery, typically within milliseconds of the commit, with no polling delay.
  • Zero added query load on the live database — the log is something the database writes anyway.
  • Requires deploying and operating extra infrastructure (Kafka Connect, Debezium) and enabling logical replication.

Polling is easy to reason about and gets a team most of the reliability benefit with almost no new infrastructure — but it adds a small delay (the polling interval) and adds repeated read load to the outbox table as it grows. CDC removes both of those downsides by reading the transaction log instead of the table itself, but it requires you to run and monitor a connector, and it can be a heavier operational lift for a small team.

A common path. Many teams start with a simple polling relay to prove the pattern works, then graduate to Debezium and CDC once event volume, latency requirements, or database load make polling impractical. The outbox table schema doesn’t need to change at all when making this switch — only the relay implementation does.

Debezium in particular ships a built-in Outbox Event Router, a Kafka Connect transform purpose-built for this exact table shape. It reads new outbox rows, extracts the aggregate_type to decide which Kafka topic the event belongs to, and uses aggregate_id as the Kafka message key — which, combined with Kafka’s per-partition ordering guarantee, ensures all events about the same order (or user, or account) arrive at consumers in the order they were created.

10 · Advantages, Disadvantages & Trade-offs

No pattern is free. The outbox pattern trades a small amount of added complexity for a large amount of reliability — and it’s worth being honest about both sides.

Advantages

  • No lost events, even across crashes, network blips, or broker downtime.
  • No distributed transactions or XA coordination required.
  • Works with almost any relational database and almost any message broker.
  • Keeps the customer-facing request fast, since it never waits on the broker.
  • Preserves per-entity event ordering when the aggregate ID is used as the message key.
  • Battle-tested tooling exists (Debezium) so teams rarely need to build this from scratch.

Disadvantages

  • Adds a new table and a new background process to operate and monitor.
  • Delivers “at-least-once,” so downstream consumers must be built to handle duplicates.
  • The outbox table can grow large and needs a cleanup or archiving strategy.
  • CDC-based relays add operational complexity (Debezium, Kafka Connect, logical replication).
  • Cross-aggregate ordering (e.g. between two different orders) isn’t guaranteed.
  • Doesn’t help if the event needs to reach a system outside your own database’s transactional boundary in real time.

The single most important trade-off to internalize is the shift from “exactly-once” thinking to “at-least-once” thinking. The outbox pattern cannot promise that a downstream consumer will see an event exactly one time — a relay retry, a broker rebalance, or a consumer restart can all cause the same event to be delivered twice. What it can promise is that the event will never be lost. Turning “at least once” into something that behaves like “exactly once” from the consumer’s point of view is the job of idempotent consumer design, which we’ll cover in the best practices section.

0
Lost events under normal operation
1+
Delivery guarantee (at-least-once)
2PC
Distributed transaction protocol avoided

11 · Performance & Scalability

Because the outbox pattern adds one extra row insert to an existing transaction, its direct performance cost is small — inserting a row is fast, and it happens inside a transaction the service was already opening for the business write. The real performance conversation is about the outbox table itself and the relay process, not the write path.

As traffic grows, a few specific pressure points show up:

Table growth

Every event ever produced adds a row. Without cleanup, the outbox table grows without bound, slowing down both writes and any polling queries against it.

Cleanup strategy

Most teams delete a row immediately after successful publish, or run a scheduled job that purges rows older than a retention window (commonly a few hours to a few days).

Polling query cost

A polling relay’s SELECT ... WHERE processed = false ORDER BY created_at needs a good index on the processed flag and timestamp, or it becomes a full table scan as the table grows.

CDC avoids table scans

Because CDC reads the transaction log rather than querying the table, it sidesteps the polling-query cost entirely — one reason large-scale systems tend to graduate to it.

On the consumer side, scalability usually comes from the message broker itself rather than the outbox pattern. Kafka, for example, scales horizontally by adding partitions and consumer instances within a consumer group, letting event processing scale independently of how many rows are being written to any single service’s outbox table. Because the aggregate ID is typically used as the partition key, load spreads evenly across partitions as long as aggregate IDs are well distributed — a skewed key (like always using a fixed “GLOBAL” aggregate) would concentrate all events onto a single partition and become a bottleneck.

Rule of thumb. If your polling interval and query cost start showing up in your database’s slow query log, that’s usually the clearest signal it’s time to move from polling to a CDC-based relay.

12 · High Availability, Reliability & Failure Recovery

The entire point of the outbox pattern is reliability, so it’s worth walking through exactly how it survives the failure scenarios that broke the naive approach earlier in this guide.

Failure ScenarioWhat Happens With the Outbox Pattern
Broker is down when the order is placedThe transaction still commits normally. The outbox row simply waits until the relay can reach the broker again — nothing is lost.
Server crashes right after commitBoth the order row and the outbox row already exist in the database. When the relay resumes (on this instance or another), it finds the unprocessed row and delivers it.
Relay process itself crashes mid-deliveryBecause the row is only marked processed after a confirmed publish, an incomplete delivery leaves the row unprocessed, and it will be retried — at the cost of a possible duplicate delivery.
Database write fails / rolls backNeither the business row nor the outbox row is saved. No event is ever created for something that didn’t actually happen — no phantom events.
Network partition between relay and brokerThe relay retries with backoff. Rows accumulate safely in the outbox table until connectivity returns.

This is why the pattern is often described as trading a hard distributed-systems problem (keeping two independent systems consistent) for an easy local problem (keeping one database consistent with itself) plus a background retry loop. Databases are extremely good at the first job. Retry loops are a well-understood, easy-to-test piece of software for the second.

For high availability, teams typically run more than one instance of the relay process for redundancy, using a distributed lock (or the natural partitioning of a Kafka Connect cluster, when using Debezium) to make sure only one instance is actively processing a given partition of the outbox table at a time — this avoids duplicate publishing from multiple relays racing each other, while still surviving the loss of any single relay instance.

Simple analogy. It’s like having two mail clerks who share one delivery route, but only one of them actually drives out with the mail truck at any given moment. If that clerk gets sick, the other one simply picks up the same route the next day. The mailbox (the outbox table) never cares which clerk empties it — it just needs someone to, eventually.

13 · Security Considerations

The outbox pattern doesn’t introduce a fundamentally new security surface, but it does touch a few areas worth treating carefully, since event payloads often travel far beyond the service that created them.

Sensitive data in payloads

Outbox rows and the events built from them are often stored, logged, and replicated more widely than the original database row. Avoid putting raw secrets, full card numbers, or unnecessary personal data directly into event payloads — reference an ID and let interested services fetch full details through an authenticated API if needed.

Broker access control

Because the relay is a privileged writer to the broker, its credentials deserve the same care as any service account — least-privilege topic permissions, rotated credentials, and no shared “god” credentials across services.

CDC connector permissions

A Debezium connector typically needs elevated database permissions to read the transaction log (e.g. REPLICATION privilege in PostgreSQL). This access should be scoped as tightly as the database allows and monitored like any other privileged account.

Payload validation downstream

Consumers should never blindly trust an event payload’s shape or contents. Schema validation (via a schema registry, JSON Schema, or Avro/Protobuf contracts) prevents malformed or unexpected events from silently corrupting downstream state.

Watch out for. Encryption in transit (TLS) between the relay and the broker, and between the broker and consumers, should be treated as non-negotiable in production — event payloads frequently carry business-sensitive information even when they don’t carry outright secrets.

14 · Monitoring, Logging & Metrics

An outbox implementation that nobody is watching is only reliable until the day it silently isn’t. A handful of metrics catch almost every real-world failure mode early.

Outbox lag

The time between a row being written and a row being marked processed. Rising lag is usually the first sign of trouble — a slow broker, a stuck relay, or a connector that has fallen behind.

Unprocessed row count

A simple, alertable count of rows still waiting to be published. A steady climb means the relay isn’t keeping up with the write rate.

Retry & failure counts

How often the relay retries a delivery, and how often it fails outright. Spikes usually correlate with broker incidents or network issues worth investigating directly.

CDC connector health

For Debezium-based relays: connector status, replication slot size (in PostgreSQL), and consumer offset lag are all critical — an unmonitored replication slot can grow unbounded and eventually fill disk on the source database.

Good logging practice includes tagging every log line related to an event with its outbox row ID and aggregate ID, so a single event’s journey — from creation, to relay pickup, to broker publish, to consumer processing — can be traced end-to-end across services. Many teams pair this with distributed tracing tools (like OpenTelemetry) that propagate a trace ID through the event payload itself, so a single trace shows the complete path an order took through five or six different services.

A practical dashboard. Three panels cover most of what you need day to day: outbox row count over time, publish latency (p50/p95/p99), and connector or relay uptime. If all three look healthy, the pattern is almost certainly doing its job.

15 · Deployment & Cloud Options

Every major cloud provider offers building blocks that fit naturally into an outbox implementation, so teams rarely need to build the infrastructure from scratch.

ComponentCommon Choices
Database (outbox table lives here)PostgreSQL, MySQL, Amazon Aurora, Azure Database for PostgreSQL, Cloud SQL
CDC / Relay toolingDebezium (self-hosted or via Kafka Connect), AWS DMS, Azure Data Factory CDC, Google Datastream
Message brokerApache Kafka (self-managed or Confluent Cloud/MSK), Amazon SNS/SQS, Azure Service Bus, Google Pub/Sub
Managed streaming platformsAmazon MSK, Confluent Cloud, Azure Event Hubs, Google Pub/Sub — all reduce the operational load of running Kafka yourself

In a typical Kubernetes deployment, the relay runs as its own lightweight deployment or, when using Debezium, as a Kafka Connect worker pod, entirely separate from the business service’s own pods. This separation matters: the relay can be scaled, restarted, or redeployed independently of the service that writes to the outbox table, and a bug in relay logic can never take down the customer-facing service, since the two only interact through the database.

For teams on managed cloud data platforms, some databases now offer built-in “outbox-friendly” features — for instance, PostgreSQL’s logical replication (the foundation CDC tools rely on) is available out of the box on most managed Postgres offerings, meaning teams can adopt CDC-based relays without operating their own database infrastructure at all.

Cost note. Polling relays cost almost nothing extra to run. CDC-based relays add the cost of running Kafka Connect (or a managed equivalent) plus the storage cost of replication slots and Kafka topics — a cost worth weighing against the latency and database-load benefits CDC provides.

16 · Databases, Message Brokers & Related Infrastructure

The outbox pattern is deliberately unopinionated about which database and broker you use — its only real requirement is that the database supports transactions, which nearly every relational database does.

Here’s a representative outbox table schema, the same shape used across most implementations regardless of the underlying database engine:

CREATE TABLE outbox_events (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    aggregate_type  VARCHAR(255) NOT NULL,
    aggregate_id    VARCHAR(255) NOT NULL,
    event_type      VARCHAR(255) NOT NULL,
    payload         JSONB NOT NULL,
    created_at      TIMESTAMPTZ NOT NULL DEFAULT now(),
    processed_at    TIMESTAMPTZ
);

-- Index that keeps polling queries fast
CREATE INDEX idx_outbox_unprocessed
    ON outbox_events (created_at)
    WHERE processed_at IS NULL;

Beyond the database and broker themselves, caching and load balancing sit around, not inside, the pattern. A load balancer distributes incoming API requests across multiple instances of the business service, none of which need any special coordination with each other — each instance can independently write to the shared database, since the database’s transactional guarantee is what keeps everything consistent, not the application instance handling the request. Caching, similarly, applies to how consumers serve reads after processing an event (for example, updating a Redis cache once an OrderCreated event is consumed) rather than to the outbox mechanism itself.

Simple analogy. Think of the database as the one referee everyone trusts. It doesn’t matter how many service instances are running behind a load balancer — they all report to the same referee, and the referee’s decision (commit or rollback) is final and instant, so there’s never a disagreement about whether an event should exist.

One nuance worth knowing: NoSQL and document databases can also support the pattern, as long as they offer transactional writes across two documents or collections in the same database. MongoDB, for instance, has supported multi-document ACID transactions since version 4.0, making an outbox collection alongside a business collection perfectly workable, with tools like MongoDB’s own Kafka connector able to tail its change streams in a CDC-like fashion.

17 · APIs, Microservices & Where the Pattern Fits

In a microservices architecture, each service is meant to own its own data exclusively — no other service is allowed to reach into its database directly. The only sanctioned ways for services to interact are through well-defined APIs (synchronous, like REST or gRPC) or through events (asynchronous, via a message broker). The outbox pattern is specifically about making that second path — the event path — reliable.

This matters because synchronous APIs and asynchronous events solve different problems. A REST call is good when you need an immediate answer: “Is this item in stock right now?” An event is good when you need to announce that something happened and let anyone interested react in their own time: “This order was just placed.” The outbox pattern only concerns itself with the second category — it has nothing to do with synchronous request/response calls, which have their own, separate reliability techniques like retries, circuit breakers, and timeouts.

Event-driven architecture

Systems built around services reacting to events rather than calling each other directly. The outbox pattern is one of the foundational building blocks that makes this style trustworthy at scale.

API composition & BFFs

Even in systems that lean on synchronous APIs for reads, events published through an outbox often keep read-optimized views (or a “backend for frontend” cache) up to date behind the scenes.

CQRS read models

Command Query Responsibility Segregation systems often use outbox-published events to keep a separate, fast, read-optimized data store in sync with the write-side database.

Saga orchestration

Long-running business processes that span multiple services (like “place order → reserve inventory → charge payment → ship”) are frequently implemented as a Saga, where each step is triggered by an outbox-published event from the previous one.

It’s worth being precise about scope: the outbox pattern guarantees that an event leaves the originating service reliably. It says nothing about what happens after that — whether the broker delivers it, whether the consumer processes it correctly, or whether a downstream failure needs to be compensated for. Those concerns belong to the broker’s own delivery guarantees and to patterns like the Saga pattern, which the outbox pattern is frequently paired with, not a replacement for.

18 · Related Patterns & Anti-Patterns

The outbox pattern rarely stands alone. Understanding its neighbors — and a few common ways teams misuse it — rounds out the full picture.

Companion patterns

Idempotent Consumer

The natural companion pattern on the receiving end: consumers track which event IDs they’ve already processed, so a duplicate delivery is safely ignored rather than applied twice.

Saga Pattern

Coordinates a sequence of local transactions across services, often triggered step-by-step by outbox-published events, with compensating actions if a later step fails.

Event Sourcing

A related but distinct idea where the event log itself becomes the primary source of truth, rather than a side effect of a business table update. The outbox pattern is often a lighter-weight stepping stone toward full event sourcing.

Listen-to-Yourself

An alternative approach where a service publishes an event first, then subscribes to its own topic to update its own database — inverting the outbox’s order but requiring the service to treat its own writes as asynchronous, which adds different complexity.

Anti-patterns to avoid

The dual write (again)

Skipping the outbox table entirely and calling the broker directly inside the request path — reintroducing the exact problem this whole guide has been explaining how to avoid.

Unbounded outbox growth

Never deleting or archiving processed rows, letting the table grow indefinitely until it degrades write and read performance across the whole service.

Non-idempotent consumers

Building downstream logic that assumes “exactly once” delivery, then breaking in production the first time a retry causes a duplicate — for example, double-charging a customer.

Giant payloads in the outbox

Stuffing an entire object graph, including large binary data or unrelated fields, into a single event payload — bloating the table and the broker’s storage for data most consumers never need.

19 · Best Practices & Common Mistakes

Teams who’ve run this pattern in production for years tend to converge on the same handful of habits. Here’s the short list worth internalizing.

1. Design consumers to be idempotent from day one

Track processed event IDs (a unique constraint on an events_processed table works well) so a duplicate delivery is a no-op rather than a bug.

2. Keep payloads small and stable

Include only what consumers actually need, and treat the event schema as a public contract — version it deliberately rather than changing field meanings silently.

3. Clean up processed rows on a schedule

Delete or archive rows shortly after successful publish, and add a safety-net job that purges anything older than a sane retention window regardless.

4. Use the aggregate ID as the partition/message key

This preserves ordering for events about the same entity, which is usually the ordering guarantee that actually matters to consumers.

5. Monitor outbox lag as a first-class metric

Treat rising lag with the same seriousness as rising error rates — it’s usually the earliest signal that something downstream needs attention.

6. Start with polling, graduate to CDC deliberately

Don’t reach for Debezium and Kafka Connect on day one if a simple scheduled job comfortably meets your latency needs — add complexity when the numbers justify it.

The outbox pattern rewards teams who treat “at least once” as a design constraint from the very first line of consumer code, not as a surprise discovered in an incident review.

A short but important note on schema evolution: because events published through the outbox often outlive the code that produced them — old messages might still be sitting in a Kafka topic days later — additive, backward-compatible changes (adding a new optional field) are safe, while removing or renaming fields consumers already depend on is not. Many teams enforce this with a schema registry that rejects incompatible changes before they ever reach production.

20 · Real-World & Industry Examples

The outbox pattern isn’t a theoretical exercise — it’s quietly running behind some of the largest software systems in the world.

Uber

Uber’s engineering blog has described using transactional outbox-style approaches to keep trip, payment, and driver-matching services consistent across its large microservices fleet, avoiding distributed transactions across dozens of independently deployed services.

Shopify

Shopify’s engineering team has publicly discussed transactional outbox implementations to reliably publish commerce events (like order and inventory changes) to their internal event bus without risking dropped events during traffic spikes like flash sales.

Streaming & media platforms

Large-scale streaming platforms commonly rely on Kafka-centric, event-driven backends where services publish state changes (like a new watch-history entry) reliably using outbox-style relays feeding into Kafka pipelines.

Banking & fintech

Financial institutions, where a lost or duplicated event around money movement is unacceptable, were among the earliest and heaviest adopters of outbox-style guarantees, often building the pattern by hand years before it had a common name.

The common thread across every one of these examples is scale and consequence: once a company operates enough independent services, and once losing an event has a real cost — a missing delivery, a stuck payment, an inventory mismatch — the outbox pattern stops being an optional refinement and becomes basic infrastructure, in the same category as a load balancer or a health check.

It’s also worth noticing what these companies have in common beyond their size: none of them treat the outbox pattern as a one-time project that gets finished and forgotten. It becomes part of the standard toolkit that new services are built with from day one, the same way a team might automatically add health-check endpoints or structured logging to every new service without debating it each time. Platform teams at these companies often build shared libraries that give any new service an outbox table, a relay, and monitoring dashboards “for free,” simply by including the library — a strong signal that the pattern has moved from a clever trick into foundational plumbing, much like TCP retransmission or DNS caching: invisible when it works, and only noticed by name when someone goes looking for how reliability is actually achieved under the hood.

21 · Frequently Asked Questions

Is the outbox pattern the same as event sourcing?

No. Event sourcing makes the event log the primary source of truth for an entity’s state. The outbox pattern keeps your normal business table as the source of truth, and simply guarantees that a related event is reliably published alongside it. They can be combined, but they solve different problems.

Does the outbox pattern guarantee exactly-once delivery?

No — it guarantees at-least-once delivery. A message will never be silently lost, but it might occasionally be delivered more than once, which is why downstream consumers need to be idempotent.

Can I use the outbox pattern without Kafka?

Yes. The pattern is broker-agnostic. It works equally well with RabbitMQ, Amazon SQS/SNS, Azure Service Bus, Google Pub/Sub, or any other messaging system — the relay simply needs a client library for whichever broker you choose.

Do I need Debezium to implement the outbox pattern?

No. Debezium is one popular way to build the relay using change data capture, but a simple scheduled polling job against the outbox table is a perfectly valid, and often good enough, starting point.

What happens to old rows in the outbox table?

Most implementations delete a row right after it’s successfully published, or run a scheduled cleanup job that purges anything past a retention window, to keep the table small and fast.

Is the outbox pattern only for microservices?

It’s most commonly discussed in a microservices context, but any single service that needs to reliably publish events after a database write — even inside a larger monolith — can benefit from the same technique.

How is this different from just using a database trigger?

A database trigger could technically insert an outbox-style row automatically, but it doesn’t solve the delivery half of the problem. You still need a relay (polling or CDC) to move that row to a message broker — the trigger only helps with the “write” half, which application code already handles cleanly inside a transaction.

22 · Summary & Key Takeaways

The outbox pattern takes a genuinely hard distributed-systems problem — keeping a database and a message broker consistent — and turns it into an easy local-database problem plus a patient retry loop. It does this without distributed transactions, without exotic infrastructure, and without asking any two independent systems to magically agree with each other.

Remember this

  • The dual-write problem happens because a database write and a message publish are two separate operations with no shared guarantee tying them together.
  • The outbox pattern fixes this by writing the business data and the event into the same database, in the same transaction — they succeed or fail together, always.
  • A separate relay (polling, or CDC via tools like Debezium) reads the outbox table and delivers events to the broker, retrying patiently until it succeeds.
  • The delivery guarantee is at-least-once, not exactly-once — so downstream consumers must be built to safely handle duplicate events.
  • Polling relays are simple and get most teams most of the benefit; CDC-based relays add real-time delivery and remove database query load, at the cost of extra infrastructure.
  • The pattern pairs naturally with idempotent consumers, the Saga pattern, and CQRS read models, and is standard infrastructure at companies like Uber and Shopify.

If there’s one idea worth carrying away from this entire guide, it’s this: reliability in distributed systems rarely comes from clever, heroic code trying to force two unrelated systems to agree in the moment. It comes from patiently borrowing a guarantee one system already provides — the database’s transaction — and building everything else around retrying until success, since retrying is a problem software is very good at solving.

“Two independent systems can never agree perfectly in the moment. One database can always agree with itself — and a patient relay can bridge the rest.”
One sentence to remember. The outbox pattern turns “save this data and tell the world about it” into a single, unbreakable database commit — because a local transaction is a guarantee, and a network call to a second system is only ever a wish.
outbox-pattern transactional-outbox dual-write-problem Debezium change-data-capture Kafka RabbitMQ SQS SNS Pub-Sub at-least-once idempotent-consumer Saga-pattern CQRS event-sourcing ACID 2PC-XA microservices event-driven-architecture PostgreSQL-WAL