Transactional Outbox
You cannot atomically write to your database and publish to a broker. The Outbox pattern makes the message part of the transaction — and the trap is that at-least-once delivery is the ceiling, not a bug.
Somewhere in your codebase there is a function that saves a record and then publishes an event about it. It looks completely reasonable. It is also the single most common source of silent data divergence in distributed systems, and it will corrupt your data eventually — not under load, not under failure injection, but on an ordinary Tuesday when a process happens to die in a window a few milliseconds wide.
The Transactional Outbox pattern fixes it. The fix is small. Understanding why the obvious alternatives do not work is the part worth your time, because the pattern's real cost — at-least-once delivery, forever — is a constraint you have to design your consumers around rather than a detail you can tune away.
The problem
Here is the code. Every system has a version of it.
async function placeOrder(order: Order): Promise<void> {
await db.orders.insert(order); // (1)
await broker.publish('order.placed', order); // (2)
}
Two writes, two different systems, no shared transaction. This is a dual write, and there is no ordering of those two lines that is correct.
Database first, broker second. The insert commits. The process is killed — a deploy, an OOM kill, a spot instance reclaimed — before line 2 runs. The order exists and nobody was told. The warehouse never ships it. The customer's confirmation email never sends. Nothing errored; there is no failed job to retry, no dead-letter queue entry, no alert. The order simply sits there being correct and invisible. You find out when the customer calls.
Broker first, database second. The event publishes. The insert then fails — a constraint violation, a deadlock, a connection drop. Now downstream systems have processed an order that does not exist. The warehouse ships goods for an order your database has never heard of. This failure mode is worse, because the damage is outside your system and you cannot roll it back.
Wrap it in a try/catch and compensate. Now you need to un-publish, which brokers do not support, or publish a compensating event, which requires the compensating publish to succeed — and you have recreated the original problem one level up.
The instinct at this point is to reach for a distributed transaction, and Two-Phase Commit genuinely does solve it in theory. In practice most modern brokers do not participate in XA at all, the coordinator becomes a availability bottleneck that can block participants indefinitely, and the latency cost lands on every write. That trade is examined in its own article; the short version is that almost nobody accepts it for this problem.
The root difficulty is that atomicity is a property of a single transactional resource. You cannot get it across two. So the pattern does not try to. It moves the message into the resource that already has transactions.
When to use it
Reach for an outbox when all of these hold:
- A state change must reliably produce a notification. The event is not optional telemetry — something downstream is required to happen because of it. Order placed, payment captured, user deactivated.
- The state lives in a transactional store. Postgres, MySQL, SQL Server, or any database where the business write and one extra INSERT can commit atomically. This is the load-bearing requirement.
- Consumers can be made idempotent, or already are. You are buying reliability at the price of duplicates. If a duplicate
payment.capturedcharges a card twice, the outbox has not helped you — it has given you a reliable way to double-charge. - You can tolerate asynchronous delivery. The message goes out after commit, not during. Anything that needs a synchronous answer from the other side needs a request/response call, not an event.
The canonical fit is a service in an event-driven topology that owns some state and announces changes to it — which is most services in most microservices architectures. It is also the reliable-publish half of a Saga: each step commits its local change and its outgoing message together, which is exactly what makes the choreography survive a crash mid-saga.
When NOT to use it
- The event is genuinely fire-and-forget. Analytics pings, cache-warming hints, "user viewed page". If losing a message costs nothing, an outbox adds a table, a relay process, monitoring, and a cleanup job to protect something that did not need protecting.
- Your store has no transactions spanning the two writes. Against a document store without multi-document transactions, or a database whose per-partition transaction scope does not cover both rows, the outbox row can commit without the business row. You have moved the dual write, not removed it. Check the guarantee, do not assume it.
- You already have an event log as your source of truth. In Event Sourcing the events are the state — appending to the log is the write. There is no second thing to keep in sync, so an outbox on top is redundant machinery. The two patterns solve overlapping problems from opposite directions, which is why they are listed as a contrast rather than a composition below.
- Duplicates are unacceptable and cannot be deduplicated. Be honest about this one. "We'll make it idempotent later" is how the outbox becomes the mechanism that reliably delivers the bug.
- You need ordering across aggregates. An outbox can preserve order per key with care. Global ordering across the whole table is a serialization point you almost certainly do not want.
Architecture
Three moving parts: the business write, the outbox table, and a relay.
flowchart LR
A[Command] --> B[(Single DB transaction)]
B --> C[orders row]
B --> D[outbox row]
B -.->|COMMIT| E{{Both, or neither}}
F[Relay] -->|poll or CDC| D
F -->|publish| G[Broker]
G --> H[Consumer]
F -->|mark sent| D
H -.->|dedupe by message id| H
The insight is that orders and outbox are two tables in one database, so one transaction covers both. Either the order exists and the message is queued, or neither happened. There is no window.
The relay then does the unreliable part — talking to the broker — where it is safe to fail, because the message is durably recorded and the relay can simply try again.
sequenceDiagram
participant S as Service
participant DB as Database
participant R as Relay
participant B as Broker
S->>DB: BEGIN
S->>DB: INSERT order
S->>DB: INSERT outbox row
S->>DB: COMMIT
Note over S,DB: atomic — the risky part is over
R->>DB: SELECT unsent LIMIT 100
R->>B: publish
B-->>R: ack
R->>DB: mark sent
Note over R,B: crash here → republish → duplicate
Look at that last note, because it is the whole trade. The relay publishes, the broker acks, and the relay dies before recording the ack. On restart it reads the same row and publishes again. This window cannot be closed — closing it would require an atomic commit across the database and the broker, which is the problem you started with. At-least-once is the ceiling of this pattern, not a shortcoming of a particular implementation.
Two ways to run the relay:
Polling. A loop that queries for unsent rows, publishes, marks them sent. Simple, portable, no extra infrastructure, debuggable with SELECT. Costs you a query every interval and adds an average of half the poll interval to publish latency.
Change data capture. A connector tails the database's replication log and publishes as rows are committed — Debezium is the common choice. Lower latency, no polling load, and it scales past what a single polling relay can do. It also introduces a stateful piece of infrastructure with its own operational learning curve. Start with polling; move to CDC when the poll interval or the relay's throughput actually becomes the constraint.
Code walkthrough
The domain write and the message are one transaction. In this repo's terms, that means the port the application service depends on takes both.
export interface OutboxMessage {
readonly id: string;
readonly type: string;
readonly aggregateId: string;
readonly payload: string;
readonly occurredAt: Date;
}
export interface OrderTransaction {
insertOrder(order: Order): Promise<void>;
enqueue(message: OutboxMessage): Promise<void>;
}
export interface Orders {
inTransaction<T>(work: (tx: OrderTransaction) => Promise<T>): Promise<T>;
}
inTransaction is the important signature. It makes the atomic scope explicit in the type, so an application service cannot express "save the order, then publish" as two separate calls — the compiler simply does not offer that shape.
export async function placeOrder(command: PlaceOrder, deps: Deps): Promise<Result<OrderId, PlaceOrderError>> {
const order = Order.place(command, deps.now());
if (!order.ok) return order;
await deps.orders.inTransaction(async (tx) => {
await tx.insertOrder(order.value);
await tx.enqueue({
id: deps.newId(),
type: 'order.placed',
aggregateId: order.value.id,
payload: JSON.stringify(orderPlacedPayload(order.value)),
occurredAt: deps.now(),
});
});
return ok(order.value.id);
}
The application service never touches a broker. It does not know one exists. That is not incidental tidiness — it is what makes this testable without any messaging infrastructure, and it is what keeps the publish decision inside the transaction boundary where it belongs.
The relay is the only component that knows about the broker:
export async function relayOnce(deps: RelayDeps): Promise<number> {
const batch = await deps.outbox.claimUnsent(deps.batchSize);
if (batch.length === 0) return 0;
for (const message of batch) {
await deps.broker.publish(message.type, message.payload, {
messageId: message.id,
partitionKey: message.aggregateId,
});
await deps.outbox.markSent(message.id, deps.now());
}
return batch.length;
}
Four details in that small function carry most of the correctness:
claimUnsent must claim, not just select. Two relay instances — or one instance and the copy you forgot was still running — will otherwise read the same rows and publish everything twice on every cycle. In Postgres:
SELECT * FROM outbox
WHERE sent_at IS NULL
ORDER BY occurred_at
LIMIT $1
FOR UPDATE SKIP LOCKED;
FOR UPDATE SKIP LOCKED lets a second relay take the next unlocked rows instead of blocking on the first relay's batch, which is what makes the relay horizontally scalable at all.
markSent per message, not per batch. Mark the whole batch after the loop and a crash mid-batch republishes every message in it, not just the one in flight.
partitionKey is the aggregate id. This is what preserves per-order ordering through the broker. Two events about the same order land on the same partition and stay in sequence; events about different orders are free to interleave, which is what lets the whole thing scale.
messageId is the deduplication key, and it must be generated when the row is written, not when it is published — a republish has to carry the same id, or the consumer cannot recognise it as a duplicate.
Which brings us to the consumer, where the pattern is actually completed:
export async function handle(message: InboundMessage, deps: ConsumerDeps): Promise<void> {
const firstTime = await deps.processed.recordIfNew(message.messageId);
if (!firstTime) return;
await deps.applyEffect(message);
}
recordIfNew is an INSERT ... ON CONFLICT DO NOTHING against a table of seen message ids, returning whether the row was new. It must run in the same transaction as the effect, or you get the dual-write problem again, one system to the right: record-then-crash-before-effect drops the message permanently, and effect-then-crash-before-record replays it forever.
An outbox without an idempotent consumer is not a reliable pipeline. It is a duplicate generator with a database table.
Performance characteristics
The write path costs one extra INSERT inside a transaction that was already open — in practice a rounding error against the business write and its indexes.
The read path is where an outbox actually goes wrong in production, and it is always the same mistake: the table grows without bound. WHERE sent_at IS NULL against a table of fifty million rows, of which four are unsent, will use an index if you have a partial one and will table-scan every poll if you do not. Two things prevent it — a partial index on the unsent predicate, and a reaper that deletes or partitions off sent rows on a retention window. Neither is optional at scale, and both are usually added after the first incident rather than before.
Publish latency for a polling relay averages half the poll interval. At 200 ms polling that is 100 ms of added end-to-end delay, which is invisible for order fulfilment and unacceptable for a UI waiting on a live update. CDC brings it to single-digit milliseconds by removing the polling entirely.
The failure mode to watch is relay lag, not relay errors. A relay that is down is loud — messages stop, alerts fire. A relay that is keeping up with 95% of the write rate is silent, and the backlog grows quietly for hours before anything notices. Alert on the age of the oldest unsent row, not on the relay's error count. That single metric catches a crashed relay, a slow broker, a poison message blocking the batch, and an under-provisioned relay, all with one threshold.
Live demo
A working outbox — write, relay, deliberate mid-publish crash, and the duplicate it produces at the consumer — is on the roadmap here. Until it is live, the code above is the load-bearing part: it is the shape this pattern takes in a hexagonal codebase, with the transaction boundary expressed in the port so the wrong thing cannot be written.
Related patterns
- Saga — composes with. A choreographed saga needs each step to commit its state change and its outgoing message atomically; the outbox is what makes that true, and without it a saga loses steps on crashes.
- Message Queue — composes with. The outbox is how messages reliably get into the queue; the queue is how they reliably get delivered from there.
- Two-Phase Commit — the alternative. 2PC solves the same atomicity problem directly, with a coordinator, blocking participants and broker support you probably do not have. The outbox trades exactly-once semantics for availability and simplicity.
- Event Sourcing — contrast with. If the event log is your source of truth there is no second write to reconcile, so the outbox is unnecessary. Reach for the outbox when you have state-oriented persistence and need events out of it; reach for event sourcing when the events are the state.
Related patterns
- Composes with: Saga
- Composes with: Message Queue
- Alternative to: Two-Phase Commit
- Contrast with: Event Sourcing