How do you stop a database write and the event announcing it from getting out of step?
Write the event into the same database transaction as the change that caused it, then publish it from there with a separate process. Because the change and the event commit together, there is no window where one exists without the other, and a crash in between leaves the system consistent.
What actually goes wrong without one?
The problem is called the dual write, and it is completely ordinary. Your code saves a record and then publishes an event about it. Two systems, two operations, no shared transaction.
If the process dies between them, the record exists and nothing downstream knows. Invoicing never fires, the notification never sends, the search index never updates. Nothing errored, nothing retried, and the inconsistency is invisible until somebody notices a missing invoice weeks later.
Reversing the order does not help. Publish first and a rollback leaves you with an event about a change that never happened, which is worse.
How does the pattern work?
The change and a row describing the event are written in one transaction. Either both land or neither does, which is the whole trick: the database's own atomicity does the work rather than distributed coordination.
A separate publisher then reads unsent rows and dispatches them, marking each as sent once the broker acknowledges it. If the publisher crashes mid-dispatch it picks up where it stopped, because the outbox is the source of truth about what has been sent.
What does it cost you?
A process you have to run and monitor, and a table that grows and needs pruning. Neither is difficult, but both are real operational surface that did not exist before.
More significantly, it gives you at-least-once delivery rather than exactly-once. If the publisher dispatches an event and dies before marking it sent, it will send again on restart. That is a deliberate trade: duplicates are recoverable, lost events are not.
Which means every consumer now has to be idempotent. If you were not planning for that anyway, the outbox has moved your problem rather than solved it.
When should you skip it?
When you have no events. If one service owns the change end to end and nothing downstream needs telling, you are adding machinery for a problem you do not have.
When a missed event is genuinely cheap. A cache that refreshes on a timer anyway does not need transactional guarantees about its invalidation.
And if your database supports change data capture, you may be able to stream the commit log directly and skip the outbox table entirely. That removes the extra write at the cost of coupling to your database's replication internals.
How we have used it
AgencyOS runs a white-label platform where an operator's work moves from lead to contract to payout, with three separate frontends reading the same 78-model domain.
On that path a dropped event is a missing invoice or a payout nobody triggered, so the outbox sits alongside queue-backed workflows and realtime gateways. Queues absorb slow work, the outbox guarantees the work was announced at all.
COMMON QUESTIONS /
Questions people also ask
Is a transactional outbox the same as a message queue?
No, they solve different halves. A queue reliably delivers a message once you have handed it over. The outbox guarantees the message gets handed over at all, atomically with the database change. Most systems that use an outbox also use a queue behind it.
Do you still need idempotent consumers with an outbox?
Yes, and more than before. The outbox gives at-least-once delivery, so a publisher that dispatches and then crashes before marking the row sent will send again. Duplicate delivery is the expected behaviour, not an edge case.
Should you poll the outbox or use change data capture?
Polling is simpler and usually fast enough, at the cost of a little latency and repeated queries. CDC streams the database's commit log with lower latency and no extra write, but couples you to your database's replication internals and adds infrastructure.
Does a transactional outbox guarantee event ordering?
Not by itself. Rows commit in order but a publisher with any concurrency can dispatch them out of order, and most brokers only guarantee ordering within a partition. If order matters, publish sequentially per key rather than assuming the outbox handles it.