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.
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.
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.
Key milestones
- 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.
- 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.
- 2010s — Microservices Go Mainstream. Chris Richardson catalogues the Transactional Outbox pattern formally as part of the broader microservices patterns language, alongside Saga and CQRS.
- 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.
- 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:
- Save the new order into its own database (so the order history shows up correctly).
- Publish an
OrderCreatedevent 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.
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.
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.
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.
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.
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.
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.
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.
- Request arrives. A customer action (placing an order, updating a profile, cancelling a subscription) triggers a service method.
- Transaction begins. The service opens a database transaction covering both the business write and the outbox write.
- 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.
- 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.
- Relay picks up the row. The polling worker or CDC connector detects the new outbox row, moments after the commit.
- Message published to the broker. The relay sends the event to the appropriate topic or queue, typically keyed by aggregate ID for ordering.
- 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.
- 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.
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 = falsequery. - 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.
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.
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.
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 Scenario | What Happens With the Outbox Pattern |
|---|---|
| Broker is down when the order is placed | The transaction still commits normally. The outbox row simply waits until the relay can reach the broker again — nothing is lost. |
| Server crashes right after commit | Both 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-delivery | Because 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 back | Neither 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 broker | The 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.
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.
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.
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.
| Component | Common Choices |
|---|---|
| Database (outbox table lives here) | PostgreSQL, MySQL, Amazon Aurora, Azure Database for PostgreSQL, Cloud SQL |
| CDC / Relay tooling | Debezium (self-hosted or via Kafka Connect), AWS DMS, Azure Data Factory CDC, Google Datastream |
| Message broker | Apache Kafka (self-managed or Confluent Cloud/MSK), Amazon SNS/SQS, Azure Service Bus, Google Pub/Sub |
| Managed streaming platforms | Amazon 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.
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.
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.
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.