Transactional outbox: a crash and retry example
Step through an outbox crash, compare direct publishing, and run a SQLite example showing why a saved message can still be delivered twice.
A transactional outbox saves a business change and a pending message in the same local database transaction. A relay publishes committed messages later. This preserves the intent to send after a crash, but a publish can repeat, so consumers still need a safe repeated-effect policy.
Save the order and its send-this-notification note together. After restarting, a worker can find the note. If it sent the notification but crashed before ticking the note off, it may send the same notification again.
Committing an order and publishing its notification are two different operations. Put the database commit first and a crash can leave an order with no notification. Put the publish first and another service may act on an order that never commits. Changing the order of those two calls does not remove the gap.
Close the gap between two writes#
In this illustrative scenario, order O7 should produce event E42. Save O7 and an outbox record for E42 in one local transaction. Either both commit or neither does. Only after commit does a separate worker, the relay, read pending records and publish them. The broker call is outside the producer’s database transaction.
Crash between saving and sending
Illustrative example: order O7 should produce notification E42. Predict which records survive a crash and whether a repeated message creates another business effect.
- Order in database
- Absent
- Outbox record
- absent
- Published copies of E42
- 0
- Committed consumer effects
- 0
- The order and pending E42 commit together.
- E42 is published and processed, but the relay crashes before recording sent.
- Restart finds E42 pending and publishes it again.
- Without a transactional processed-id check there are two effects; with it there is one.
The outbox preserves delivery intent, not one-time execution. Enable JavaScript to compare the policies on the same failure.
One message id, one producer transaction, one relay and one crash. Restart retries only persisted outbox work, not an entire HTTP request. Each accepted publish is immediately consumed here; broker delay, retention, poison messages and concurrent workers are omitted. The consumer id and effect share a separate local transaction. External emails or payments need their own retry contract.
First choose a crash after commit. Direct publishing leaves no pending record for the restarted worker; the outbox retains E42. Then move the crash to after publishing and turn consumer deduplication off. The relay finds E42 pending and sends it again, producing two effects in the model. Turn the processed-id check on and repeat: there are still two published copies, but only one committed consumer effect.
This is the boundary described by the Transactional Outbox pattern: the local state change and pending message are atomic; delivery to a different system is separate. The broader transaction-boundaries guide explains how to choose what belongs in that local commit.
Run the pending-message example in SQLite#
Save this as outbox.py and run python3 outbox.py. It uses Python’s standard library. The producer has a temporary SQLite file; the consumer has a separate in-memory database. Calling consume stands in for delivery, so this tests local transactions and repeated consumption, not a broker or concurrent relay workers.
The first transaction rolls back, and both tables remain empty. The successful transaction saves the order and E42. Closing and reopening the producer database finds E42 still pending because its sent marker was deliberately omitted. Both deliveries use the same id, and the consumer’s unique processed-id row prevents a second effect. The output is two deliveries of E42; one committed consumer effect.
SQLite’s transaction documentation defines the local commit and rollback behaviour used here. Reopening a file is a controlled restart boundary, not proof of power-loss durability under every filesystem or database setting.
A sent marker does not prevent every duplicate#
Marking sent before publishing trades duplicates for loss: a crash between those actions leaves work marked complete that never reached the broker. Marking it afterwards preserves retryable work but admits the duplicate shown above. A publish timeout can also mean that the broker accepted the message but the reply was lost.
The Idempotent Consumer pattern puts a processed-message identifier and the business update in the consumer’s own transaction. Use a stable id across retries and scope it to the subscriber when several subscribers share the table. Keep the identifier for the supported replay window. Recording it before an unrelated commit can lose work; recording it after that commit can repeat the effect. Safe retries follows the same question across request boundaries.
An email or payment API is not part of that local transaction. Use its supported idempotency contract or another explicitly coordinated workflow. Do not label all downstream work exactly-once because the example’s single database effect happens once.
Ordering also needs a design. AWS’s outbox guidance calls out duplicate processing and message order. A production design may carry an entity id and sequence number and coordinate workers accordingly. A timestamp or an ORDER BY in one worker does not prevent a second worker from publishing or finishing first. The existing event-delivery article separates log order from processing order.
What a real relay still needs#
The SQLite example deliberately has one message and one worker. A real implementation must define how workers claim pending rows, recover abandoned claims, back off after errors and surface messages they cannot publish. Observe the age of the oldest pending work as well as the count: a busy relay can still leave one important message stuck.
Delivery depends on the relay eventually running, recoverable failures, retained work and the broker’s acceptance contract. The outbox alone cannot overcome permanent unavailability or an invalid payload. Plan cleanup and reconciliation against those conditions, rather than treating an empty table as proof that every downstream action completed.
Ordinary current-state tables can use this pattern; event sourcing is a separate choice about reconstructing state from history. Use an outbox when the local change must leave durable publication work. It is unnecessary for a change that has no cross-system delivery requirement.
Questions people also ask
3 QUESTIONSDoes the outbox provide exactly-once delivery?
No. A relay can publish a message and crash before marking it sent, then publish it again. A consumer can prevent a second local effect by committing a processed-message id and the effect in the same transaction. That does not automatically include an external API call.
Do I need event sourcing to use an outbox?
No. An ordinary application can update its current-state tables and insert an outbox row in one transaction. Event sourcing decides what records determine application state; the outbox preserves work that must be published after a commit.
Can I delete an outbox row immediately after publishing?
A relay needs a defined acceptance condition and recovery policy before marking or removing work. A successful socket write alone may not mean durable broker acceptance. Retention, reconciliation and replay requirements determine when cleanup is safe for the particular system.