Outbox Pattern
Write the letter and drop it in the mailbox in the same motion.
The simple version
If “save this data” and “tell everyone else about it” are two separate operations, there’s always a moment where the first succeeds and the second doesn’t — a crash, a network blip, whatever. The Outbox Pattern makes both happen atomically, by writing the “message to send” as just another row in the same database transaction as the actual change.
The analogy
You write an important letter and want to mail it. If “writing the letter” and “walking it to the mailbox” are two separate steps, there’s a real chance you write it, get distracted by literally anything, and it sits on your desk forever — never sent, even though as far as you remember, you “dealt with it.”
The fix people actually use: you write the letter and seal it in an envelope addressed and stamped, right at your desk — the sending is already baked into the writing. Later, whenever the mail carrier swings by, they just grab whatever’s sealed and stamped in your outbox tray and send it. Even if you get hit by a bus five minutes after sealing it, the letter still goes out, because the commitment to send it happened at the same moment as writing it — not as a separate step you might forget.
The bridge
This maps almost exactly onto a very common bug in distributed systems:
async function placeOrder(order: Order) {
await db.save(order); // step 1: succeeds
await messageBus.publish("OrderPlaced", order); // step 2: server crashes right here
}
If the process dies between those two lines, the order exists in the database, but nobody downstream — inventory, shipping, billing — ever finds out. The database and the message bus are two separate systems with no shared transaction; you cannot make “save” and “publish” atomic against each other directly.
The Outbox Pattern’s fix: instead of publishing directly, write the event as a row in an outbox table, in the exact same database transaction as the order.
BEGIN TRANSACTION
INSERT INTO orders (...)
INSERT INTO outbox (event_type, payload) VALUES ('OrderPlaced', ...)
COMMIT
Since both inserts are in one transaction against one database, they’re atomic by construction — either both happen or neither does. A separate background process then polls the outbox table, publishes each row to the message bus, and marks it sent. If that publisher crashes, no data is lost — the unsent row is just still sitting there, waiting for the next poll.
In code
async function placeOrder(order: Order) {
await db.transaction(async (tx) => {
await tx.insert("orders", order);
await tx.insert("outbox", {
eventType: "OrderPlaced",
payload: JSON.stringify(order),
sentAt: null,
});
}); // both rows committed together, or neither is
}
// Separate process, running continuously
async function relayOutbox() {
const pending = await db.query(
"SELECT * FROM outbox WHERE sentAt IS NULL ORDER BY id"
);
for (const row of pending) {
await messageBus.publish(row.eventType, JSON.parse(row.payload));
await db.update("outbox", row.id, { sentAt: new Date() });
}
}
The relay might publish the same event twice if it crashes between publishing and marking it sent — so consumers of these events need to be idempotent (safe to process the same event more than once). That’s the one trade-off you accept in exchange for “never silently loses an event.”
When you don’t need it
If your system only ever writes to one database and never has to notify anything else, there’s no dual-write problem to solve — skip it. It’s also overkill if occasionally missing a downstream notification is genuinely fine for that specific event (e.g. a “user viewed page” analytics ping). Reach for it when a lost event would mean silently wrong state somewhere downstream — a missed payment confirmation, a shipment that never gets created.