A publish after commit is not an outbox
The pattern shows up everywhere we emit work after a write: Shopify order lands, we persist the operational record, then we publish to a queue so a worker can render a PDF and hit PrintNode. Two successful calls look fine in the happy path. A crash, deploy, or broker blip between them leaves a committed order with no delivery record — and nothing to retry, because the event was never written. A transactional outbox closes that window. A publish after commit does not.
The dual-write that looks like delivery
Dual-write is two independent side effects treated as one: mutate your database, then call a queue, webhook endpoint, or email API. Each call can succeed or fail on its own. The database transaction does not cover the second hop. When the process dies after COMMIT and before the publish returns, you have durable business state and zero proof that anyone downstream was told.
Teams paper over it with "we will reconcile later" or a nightly scan. Those are recovery tools, not the delivery mechanism. If the only place the intent to notify lived was in a process that already exited, there is nothing left to reconcile except the absence of a side effect you hoped for.
What an outbox actually is
An outbox is a row (or CDC-visible change) written in the same database transaction as the business mutation. Order inserted, outbox row inserted, one COMMIT. Either both land or neither does. A separate relay — a poller with SKIP LOCKED, or a CDC stream — claims pending rows and performs the HTTP or queue publish. Delivery becomes a property of durable state, not of the request thread that handled the webhook.
That split is the point. The ingest handler stays fast and boring: validate, upsert, write outbox, commit, return 200. The relay owns retries, backoff, and marking a row sent. If the relay crashes after a successful POST but before marking sent, you get a duplicate delivery — at-least-once — which is why consumers still need an event id and an idempotent apply path. You traded silent loss for possible duplicates. That is the correct trade for ticket printers and partner webhooks.
At-least-once is the promise — say it out loud
Exactly-once across your database and someone else's HTTPS endpoint is not a thing you can honestly advertise. Networks lie, workers restart, and a 200 that never reached your process looks identical to a timeout. The outbox makes loss the hard failure mode to invent and makes duplicates the easy one. Design the payload with a stable event id minted when the business fact is written — not when the relay happens to run.
On the consumer side, store that id in a processed-events table (or equivalent) in the same transaction as the side effect. Al's Flowers already treats inbound Shopify deliveries this way: assume the network lied, key on order id, reconcile gaps. Outbound work deserves the same discipline in the other direction.
What we watch once it is out of the request path
Outbox lag is the metric that matters: age of the oldest unsent row, and count of rows stuck past the retry budget. When lag climbs, the business path can still be green while printers go quiet. Alert on lag, not only on HTTP error rates from the relay — a frozen worker produces neither errors nor deliveries.
Give exhausted rows a dead-letter state with the last status code and body snippet. Replay is a deliberate action from that state, not an infinite retry loop. Prune or archive sent rows on a schedule so the table stays a queue, not an unbounded history. Retention should outlive your longest legitimate replay window, including anything sitting in dead letter.
How we check it before it ships
Kill the process between a forced COMMIT of the business row and the would-be publish. Confirm an outbox row exists and that a restarted relay delivers it. Force the relay to POST successfully then crash before marking sent — confirm a second delivery arrives and the consumer ignores the duplicate on event id.
Hold the subscriber at 500s through the backoff schedule and confirm the row lands in dead letter with enough context to replay by hand. If the only way to "recover" a missing ticket is a human noticing a quiet printer, you still have a dual-write with better vocabulary.