
Services in event-driven architectures routinely need to persist a change in a database and notify other services through a message broker. Because the database and the broker are independent systems, the two writes can fail separately, leaving stored state and published events out of sync. Distributed transactions could coordinate such writes in principle, but blocking coordination and weak broker support make them impractical for most microservices. The transactional outbox pattern takes a different route: it keeps every write inside one local database transaction and turns publishing into a separate, retryable background job. This article walks through the pattern step by step — the dual-write failure modes, the atomicity mechanism, the participants, the outbox table design, the relay loop with row locking, and ordering guarantees. It is written for developers new to reliable event publishing who want to understand how the pieces fit together before adopting a framework.

In an event‑driven microservice, a single business operation often requires two actions that target different systems:
OrderCreated event to a message broker such as Kafka.Because the database and the broker each manage their own commit protocol, the two writes are independent. Consequently, any of the following failure modes can occur, breaking the expectation that the persisted change and the emitted event stay in lockstep.
| Failure mode | Symptom | Why it happens |
|---|---|---|
| DB commits, broker publish fails | The order exists in the DB but downstream services never see the OrderCreated event. | The process crashes or the network drops after the transaction commits but before the publish succeeds【exashard.com】 |
| Broker accepts, DB transaction rolls back | Consumers receive an event for a non‑existent order, leading to “ghost” processing. | The DB write fails (e.g., constraint violation) after the broker has already persisted the message【conduktor.io】 |
| Process crashes between the two steps | Either the DB row is left without an event, or the event is left without a row, depending on where the crash occurs. | No coordination between the two systems leaves a window of vulnerability【milanjovanovic.tech】 |
| Network partition or timeout | The publish request times out, the service retries, and the broker may have already stored the message. | Retries can create duplicate events because a timeout does not guarantee that the broker did not receive the first attempt【dteather.com】 |
| Partial retries producing duplicates | Downstream services process the same OrderCreated event more than once, causing idempotency challenges. | A naive retry after a timeout can re‑publish the same message, resulting in duplicates【dteather.com】 |
These scenarios share a common root cause: no atomic boundary spanning the database and the broker. Each system can succeed or fail on its own, and there is no built‑in mechanism to roll back the other side when one side fails. The practical impact is silent inconsistency—data loss, duplicate processing, or orphaned events—that often surfaces only as operational incidents later.
The goal, which drives the transactional outbox pattern, is a bidirectional guarantee: an event is published if and only if the related business change commits. Achieving this guarantee requires moving the publish step inside the same local transaction (by writing to an “outbox” table) and letting a reliable background worker later deliver the events, thereby eliminating the dual‑write problem described above.

Two‑phase commit (2PC) or XA transactions can, in theory, keep a relational database and a message broker in a single atomic operation. In practice, most microservice stacks reject this approach for several inter‑related reasons.
Blocking and coordinator fragility – 2PC locks all participants during the prepare phase and holds those locks until the commit phase completes. If the coordinator crashes while participants are waiting for a decision, the resources remain locked and must be recovered manually, which can stall the entire service dev.to – “Two‑Phase Commit attempts to coordinate …”.
Latency and throughput penalties – Each participant requires an extra round‑trip to the coordinator for the prepare and commit messages. This adds measurable latency to every write and creates a bottleneck: a single slow participant (e.g., the broker) delays the whole transaction blog.swcode.io – “Two‑phase commit attempts to coordinate …”.
Lack of broker XA support – Modern brokers such as Kafka and RabbitMQ do not implement the XA protocol required for a true distributed transaction. Without native support, the service would need a custom transaction manager that cannot guarantee atomicity across the broker and the database milanjovanovic.tech – “Outbox pattern for solving the dual‑write problem” and bicarait.com – “Designing Idempotent Event Pipelines”. Consequently, a 2PC deployment is often impossible regardless of performance cost.
Tight coupling and availability loss – By tying a service’s write path to both its database and the broker, any broker outage blocks database writes. This violates the microservice principle of independent scalability and reduces overall system availability microservices.io – “Transactional Outbox pattern”.
Version‑ and product‑sensitivity – XA support is not uniform. Some databases expose XA‑capable drivers, while others do not; likewise, broker implementations differ across versions. Teams must verify the exact stack (e.g., Oracle XA vs. PostgreSQL two‑phase commit, Kafka transactional API vs. classic producer) before attempting a cross‑system 2PC pasksoftware.com – “Distributed Transaction Patterns”.
Because of these drawbacks—blocking locks, added latency, missing broker support, and reduced availability—the transactional outbox pattern deliberately confines atomicity to a single local database transaction, leveraging the database’s built‑in ACID guarantees instead of a heavyweight, cross‑system commit protocol.

The transactional outbox relies on a single ACID transaction that writes both the domain record and the corresponding event record. The typical flow is:
INSERT INTO orders (…).pending).BEGIN;
INSERT INTO orders (id, customer_id, amount) VALUES (…);
INSERT INTO outbox (event_type, payload, state, created_at)
VALUES ('order.created', …, 'pending', now());
COMMIT;
Because the outbox table lives in the same relational database, the transaction’s ACID guarantees apply to both rows. If the commit succeeds, the order row and the outbox row are durable; if the transaction aborts, neither row exists, so no stray event can be emitted. This atomicity eliminates the classic dual‑write problem without requiring two‑phase commit or any special database feature—any single‑node transaction that supports standard BEGIN/COMMIT semantics is sufficient【1†https://dteather.com/blogs/the-outbox-pattern/】.
| ACID property | Effect on the outbox pattern |
|---|---|
| Atomicity | Both rows appear together or not at all. |
| Consistency | The outbox entry always reflects a real state change. |
| Isolation | Concurrent transactions cannot see a half‑committed outbox row. |
| Durability | After COMMIT, both rows survive crashes. |
Once the transaction is committed, a background relay loop reads pending outbox rows, publishes them to the broker, and marks them as published. Broker failures therefore only delay delivery; they cannot corrupt the domain state because the broker is never part of the atomic step【3†https://microservices.io/patterns/data/transactional-outbox.html】. This simple, single‑transaction approach is the cornerstone of the outbox pattern.

Sender – The microservice that executes a business operation and decides that an event must be emitted. Instead of calling the broker directly, the sender writes the event into the same database transaction that persists the domain change. This guarantees that the state change and the corresponding outbox entry are atomically committed 【0†source】.
Database & Message Outbox – The relational (or NoSQL) database stores both the aggregate data and a dedicated outbox table. Each outbox row contains at least an identifier, the event payload, a status flag (e.g., pending, sent), and optional metadata such as created_at or attempts. Because the row is created inside the business transaction, a failure rolls back both the aggregate and the outbox entry, eliminating the dual‑write gap 【0†source】.
Message Relay – An asynchronous worker (or separate service) that polls the outbox for rows whose status is pending. For each row it:
If the worker crashes after publishing but before recording success, the row will be processed again on restart, so downstream consumers must be idempotent 【1†source】. The relay can be implemented as an in‑process scheduled task, a background thread, or a stand‑alone deployable service.
Scheduler – A lightweight component that triggers the relay at regular intervals (e.g., every few seconds). The scheduler scans for rows marked unsent or failed and invokes the relay’s publish loop. Successful attempts flip the status to sent; failures leave the row untouched, letting the next run retry. This retry‑until‑success loop provides at‑least‑once delivery even when the broker is temporarily unavailable 【2†source】.
Coordination for Multiple Relays – When several relay instances run concurrently, they must avoid duplicate sends. Common approaches include row‑level locking (SELECT … FOR UPDATE SKIP LOCKED) or an optimistic‑concurrency flag (processed_by). These mechanisms ensure that only one instance claims a given outbox row at a time, while still allowing horizontal scaling of the publishing side 【2†source】.
By isolating the publish path from the request‑handling path, the pattern keeps API latency low, centralises retry/back‑off logic in the scheduler/relay, and guarantees that every committed state change eventually results in a broker event.

A production‑ready outbox table should be small enough to fit any domain while exposing all metadata the relay needs to publish an event without touching business code. A common minimal schema looks like this (PostgreSQL example):
CREATE TABLE outbox (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
aggregate_type VARCHAR(255) NOT NULL,
aggregate_id VARCHAR(255) NOT NULL,
topic VARCHAR(255) NOT NULL,
event_type VARCHAR(255) NOT NULL,
payload JSONB NOT NULL, -- MySQL uses JSON
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
published_at TIMESTAMPTZ,
attempts INT NOT NULL DEFAULT 0
);
| Column | Reason for inclusion |
|---|---|
| id | Stable UUID that consumers can store for idempotent processing. It is the primary key and the deduplication token [1]. |
| aggregate_type / aggregate_id | Identify the source entity. aggregate_id doubles as the broker‑message key, guaranteeing per‑entity ordering on partitioned systems like Kafka. |
| topic | Destination topic or exchange. By persisting the routing target, the relay does not need any domain logic to decide where to send the record. |
| event_type | Fully‑qualified name of the event (e.g., com.myapp.user.Created). Enables consumers to locate the correct schema or deserializer. |
| payload | Serialized event body (JSON, protobuf, Avro, …). PostgreSQL prefers JSONB for indexing; MySQL uses plain JSON [2]. |
| created_at | Timestamp of insertion; useful for chronological ordering, latency measurement, and cleanup jobs. |
| published_at | Filled by the relay only after a successful publish. A NULL value marks the row as pending. |
| attempts | Counter of publish attempts; rows that exceed a threshold can be flagged as “poison” for manual inspection. |
Because the table grows indefinitely, the relay should scan only rows that have not yet been published. A partial index on the primary sequencing column (or on created_at) with the condition published_at IS NULL keeps reads cheap even at high volume [2]. Example:
CREATE INDEX idx_outbox_unpublished
ON outbox (created_at)
WHERE published_at IS NULL;
JSONB (binary JSON) for fast containment queries, while MySQL’s JSON column stores text‑based JSON. Choose the type that matches your database version.gen_random_uuid() is the default in PostgreSQL 9.4+, whereas MySQL requires UUID_TO_BIN(UUID()) or an application‑side generator.Keep the schema generic. Every column needed for routing, ordering, or idempotency lives in the table, so the background relay can publish a row purely from the database view, without importing any domain‑specific classes or libraries. This separation simplifies testing, enables language‑agnostic relays, and preserves the transactional guarantee that all writes (domain changes + outbox insert) succeed or fail together.

FOR UPDATE SKIP LOCKEDThe relay is the background loop that moves outbox rows from the database to the message broker. A robust implementation follows this transactional pattern:
Start a transaction.
Select a batch of rows that have not yet been published, ordered by created_at (or an explicit sequence column) and limited to a configurable size:
SELECT id, aggregate_id, topic, payload
FROM outbox
WHERE published_at IS NULL
ORDER BY created_at
LIMIT 100
FOR UPDATE SKIP LOCKED; -- PostgreSQL, MySQL 8.0
Publish each row to the target broker, using topic as the destination, aggregate_id as the partition/key, and payload as the message body.
Mark the row as published (e.g., set published_at = now()) or delete it.
Commit the transaction, which releases the row‑level locks.
Because the SELECT … FOR UPDATE acquires exclusive row locks, any other worker that runs the same query will not see the locked rows. The SKIP LOCKED clause tells PostgreSQL and MySQL 8.0 to ignore rows already locked by another transaction, so each parallel worker receives a disjoint batch and duplicate deliveries are avoided【2†https://exashard.com/posts/transactional-outbox-pattern/】.
If the relay runs on several instances without coordination, a naïve SELECT … FOR UPDATE without SKIP LOCKED still prevents double‑processing, but workers block each other while waiting for locks, dramatically reducing throughput. The pattern still works, but it forces a serial execution order and can become a bottleneck under load.
| Database | SKIP LOCKED availability | Equivalent approach |
|---|---|---|
| PostgreSQL | Native support (all modern versions)【5†https://tiarebalbi.com/en/blog/the-transactional-outbox-is-not-a-queue】 | – |
| MySQL | Added in 8.0【3†https://www.npiontko.pro/2025/05/19/outbox-pattern】 | – |
| SQL Server | No SKIP LOCKED clause | Use TOP(N) … WITH (ROWLOCK, UPDLOCK, READPAST) hints to lock rows and skip locked ones【4†https://www.npiontko.pro/2025/05/19/outbox-pattern】 |
SKIP LOCKED, occasional duplicate attempts can happen if a worker crashes after publishing but before marking the row. Make the broker handling idempotent (e.g., include a unique message key).By combining row‑level locking with SKIP LOCKED (or the SQL Server hint set), the relay can be scaled horizontally: adding more worker processes simply increases throughput without risking out‑of‑order or duplicated events.

When an outbox row is created, the service typically stores both a created_at timestamp and a database‑generated monotonic identifier (e.g., a BIGSERIAL column).
Timestamps give a quick view of when an event was produced and enable simple lag monitoring. However, concurrent transactions can receive the same timestamp, and clock drift between application nodes can cause the observed order to diverge from the actual commit order. Relying on time alone therefore risks mis‑ordering events.
Sequence numbers eliminate this ambiguity. Because the database assigns the BIGSERIAL value after the row is inserted, it is guaranteed to be strictly increasing and gap‑free, reflecting the true commit order of the originating transaction【2†url】. The relay loop can safely query the outbox with ORDER BY sequence_id (or the primary key) and publish events in exactly the order they were persisted【3†url】.
Most message brokers (Kafka, Pulsar, AWS Kinesis, etc.) preserve the order of records within a single partition. By using the aggregate identifier (e.g., order_id, user_id) as the message key, all events for that entity are routed to the same partition. The broker then guarantees that downstream consumers receive those events in the order the relay produced them【4†url】. This approach scales horizontally because partitions for different aggregates can be processed in parallel, while ordering across distinct aggregates is intentionally left undefined.
Storing both timestamps and sequence numbers enables reliable replay scenarios:
Achieving global total ordering—where every event across all aggregates is strictly ordered—requires a single publishing thread or a coordination layer that serialises all outbox reads. While this provides a universal order, it becomes a throughput bottleneck and limits the scalability benefits of a partitioned broker【5†url】. In practice, most systems accept per‑aggregate ordering as sufficient and reserve total ordering only for scenarios with stringent sequencing requirements.

The outbox processor runs inside the same local transaction that writes the business data, so an event is never published for a rolled‑back transaction. Once the row is committed, the relay keeps trying to publish it until it receives an acknowledgment. This gives the pattern at‑least‑once delivery – a committed event is never lost, but duplicates can appear if the relay crashes after a successful send but before marking the row as processed 【1†https://dev.to/thejoud1997/the-outbox-pattern-explained-complete-guide-504c】. Consequently, downstream services must be idempotent, typically by deduplicating on a stable event identifier that travels with each message 【2†https://exashard.com/posts/transactional-outbox-pattern/】. Exactly‑once semantics are outside the scope of this article.
Because every successful publish leaves a row, the outbox grows indefinitely unless it is cleaned up. Common practices are:
processed_at timestamp is older than a configurable window (e.g., 7 days) 【4†https://exashard.com/posts/transactional-outbox-pattern/】.processed_at column; dropping an old partition is cheap and instantly reclaims space 【4†https://milanjovanovic.tech/blog/implementing-the-outbox-pattern】.The simplest relay implementation repeats a query such as SELECT … WHERE processed = false on a timer. When the processed column lacks an index this becomes a full‑table scan, which can dominate the primary database’s I/O 【6†https://dev.to/tylerjusfly/the-transactional-outbox-pattern-54a4】.
processed (or processed_at) mitigates scan cost and speeds up deletions 【4†https://milanjovanovic.tech/blog/implementing-the-outbox-pattern】.When latency requirements tighten or the outbox table becomes large, many teams switch to a push‑based approach.
A “poison” row—e.g., malformed JSON or an unknown destination—will cause the relay to retry indefinitely, blocking later events. Best practice is to track an attempts counter and, after a configurable threshold (commonly 3 retries), move the row to a dead‑letter table and raise an alert 【7†https://exashard.com/posts/transactional-outbox-pattern/】. This prevents a single bad message from stalling the entire pipeline while preserving the payload for later inspection 【8†https://tiarebalbi.com/en/blog/the-transactional-outbox-is-not-a-queue】.
Instead of polling, a change‑data‑capture (CDC) connector can tail the database’s transaction log (WAL, binlog, etc.) and push newly inserted outbox rows directly to the broker. Advantages include:
The trade‑off is operational complexity: you must manage replication slots, offsets, and connector health. CDC is typically adopted after a poller’s load or latency becomes a bottleneck.