Engineering · Patterns

The Outbox Pattern: Why Every Cross-System Write Belongs in a Queue

A two-hour vendor outage during a warehouse ERP rollout cost us nothing - because every outbound write was sitting in an outbox waiting to drain, not in a webhook handler waiting to time out. Here is the pattern, in seventy lines of pseudo-code, with the story that taught us not to skip it.

Μοιραστείτε
The Outbox Pattern: Why Every Cross-System Write Belongs in a Queue - Tec Dynamics

In short

The transactional outbox pattern is a small piece of plumbing that buys you enormous resilience: instead of posting to a downstream system inside a request handler, you write the intent to an outbox table in the same transaction as the business write, and a worker drains the outbox to the downstream system with retry. When the downstream system goes down, nothing is lost; when it comes back, the queue empties. This piece is about why it is the single most under-rated pattern in integration work, and the warehouse ERP rollout where skipping it would have cost us real customer events. The full project breakdown is in our warehouse ERP integration case study.

Why direct posting in handlers breaks

Picture the obvious shape of an integration: a customer pays, your webhook handler fires, and inside the handler you POST the order to the downstream accounting system. It works in development. It works in QA. It works in production for three weeks. Then the accounting vendor has a maintenance window you did not know about, and for ninety seconds every POST times out with a 504. By the time you find out the next morning, four real customer orders have vanished into a stack trace that nobody read.

This is not a hypothetical. It is how almost every integration outage we have ever seen starts. The handler did its job - it ran the code you asked it to run - but the code you asked it to run made an implicit assumption ("the downstream system is available") that the universe declined to honour.

The fix is not better error handling inside the handler. The fix is to move the cross-system call out of the handler entirely.

The pattern, in one picture

┌──────────────────────────────────────────────────────────────────┐
│  HANDLER (single DB transaction)                                 │
│                                                                  │
│    INSERT INTO orders        (…) VALUES (…);                     │
│    INSERT INTO outbox        (event_type, payload, status)       │
│                              VALUES ('OrderPaid', {…}, 'ready'); │
│    COMMIT;                                                       │
└──────────────────────────────────────────────────────────────────┘
                                ↓
┌──────────────────────────────────────────────────────────────────┐
│  WORKER (separate process, runs on a loop)                       │
│                                                                  │
│    SELECT * FROM outbox WHERE status = 'ready' LIMIT 50;         │
│    for each row:                                                 │
│        POST to downstream system with externalReference          │
│        on success: UPDATE outbox SET status='sent' WHERE id=…    │
│        on failure: UPDATE outbox SET                             │
│             attempts=attempts+1, next_try=now()+backoff WHERE id │
└──────────────────────────────────────────────────────────────────┘

The crucial property is the single transaction in the handler. The order write and the outbox write either both succeed or both fail. There is no window where the order is in your database but the intent to forward it is not. The worker can crash, the downstream can be down, the network can blip - the outbox row stays put, with its original payload, until someone successfully drains it.

Three details that matter more than the diagram

1. Idempotency on the consumer side

The outbox guarantees at-least-once delivery; the worker may retry the same row twice (handler crashed after the POST returned 200 but before the row was marked sent, network ate the response, etc.). The downstream system must treat the call as "create-or-update by external reference," with your outbox row id (or the source business id) as the reference. If it does not, you will eventually create duplicates. This is non-negotiable. We covered the same pattern in the WooCommerce ↔ Entersoft article - it is the single most common cause of duplicate orders in ERP integrations.

2. Exponential backoff, with a cap

A naive retry every few seconds will hammer a downstream system that is already struggling. We use next_try = now() + min(60s × 2^attempts, 1 hour) - doubling each time, capped at one hour. After a configurable number of attempts (we default to twelve), the row gets moved to a dead-letter table with the last error attached, and an ops alert fires. Twelve attempts at exponential backoff capped at an hour gives a worst-case retry window of roughly twenty-three hours, which is comfortably longer than any real-world vendor outage we have seen.

3. Order, where it matters

If the downstream system is sensitive to event order - e.g. an order-paid event must arrive after the order-created event for the same id - the worker has to pick rows in insertion order per business entity. We use a partition_key column (typically the order id or customer id) and a SELECT … FOR UPDATE SKIP LOCKED with a per-partition ordering. Where order does not matter - independent stock updates, unrelated customer creations - we drain in parallel for throughput.

The story that taught us

The warehouse ERP rollout went live on a Wednesday. The accounting vendor had a planned maintenance window the following Tuesday morning - they emailed us about it on the Monday afternoon, which we appreciated, and which we duly did nothing about, because the outbox pattern had been shipped from day one. (Not because we are wise, but because we had hit this exact problem on a different integration the previous year and resolved never again.)

For two hours that Tuesday, the accounting platform refused every API call. The middleware kept receiving warehouse events at its usual rate. Outbox rows piled up. Nothing on the warehouse floor or in the CRM noticed anything different - invoices appeared a few hours later than usual, that was the only observable effect. When the platform came back, the worker drained the backlog in under three minutes.

The incident report was a paragraph. Without the outbox, it would have been a full post-mortem with a list of customer call-backs.

When you don't need it

Two cases where we skip the outbox: (1) calls to your own internal services in the same datacentre where you control both ends and a synchronous failure is recoverable from the caller's side; (2) calls that are purely advisory and where dropping a message is genuinely fine (e.g. a non-critical analytics ping). For literally everything else - every webhook into an ERP, every push to an accounting tool, every event into a CRM owned by someone else, every payment-side notification - we ship the outbox from day one.

The pattern is not novel. It is described in every textbook on distributed systems written after about 2015. The only reason it gets skipped is that the "direct POST in the handler" version works just often enough to feel like it might be enough. It is not. Ship the outbox.

Working on an integration? See our ERP integration services, our API integration service or our guide on API integration architecture.

Σχεδιάζετε ένα παρόμοιο έργο;

Πείτε μας για το stack σας και τι θέλετε να ολοκληρώσετε. Απαντάμε εντός 2 εργάσιμων ημερών με ξεκάθαρο σκοπό και ενδεικτική τιμολόγηση.

Ζητήστε Δωρεάν Συμβουλευτική ← Πίσω στο Blog