The Outbox Pattern: A SaaS Reliability Cheat Code
The outbox pattern solves the dual-write problem by storing side effects in the same database transaction as the business event, then delivering them asynchronously from a separate relay process. I use it whenever a service needs to publish to a message broker or call a webhook without risking a partially applied state change that leaves data inconsistent.
Written by Yashveer Singh, founder of Yashveer Labs.
What you actually need to know
- The outbox pattern prevents split-state bugs caused by writing to a database and a message broker in separate operations.
- The pattern requires nothing special. One extra table in your existing database, a relay process, and idempotent consumers.
- The relay process is the seam that absorbs retries, backpressure, and delivery failures, so the business transaction does not have to.
- Downstream consumers must be idempotent. The pattern guarantees at-least-once delivery, not exactly-once.
- In my experience, teams that adopt the outbox pattern after their first dual-write incident adopt it permanently.
| Approach | Atomicity with DB write | Delivery guarantee | Retry on failure | Complexity |
|---|---|---|---|---|
| Direct broker publish | No | Depends on broker | Manual or lost | Low |
| Job queue (Sidekiq, BullMQ) | No (separate enqueue) | At-least-once | Yes | Low |
| Outbox pattern | Yes | At-least-once | Yes | Moderate |
| Saga pattern | Yes, distributed | At-least-once | Yes, per step | High |
The core argument
Every SaaS that publishes events eventually runs into the same bug. The payment record is saved. The broker publish fails. The invoice email never sends. The customer waits. The support ticket arrives. The engineer adds a retry. The retry fires twice. The customer gets two emails. None of this is a clever failure mode. It is the predictable consequence of treating two side effects as a single operation when the runtime makes no such guarantee.
The outbox pattern is the fix. It does not prevent failures. It contains them. The idea is simple: write the business data and the event description into the same database transaction. If the transaction commits, the event exists durably. If it rolls back, the event was never created. There is no window where the database is updated and the event is not.
A relay process, running separately, reads undelivered events from the outbox table and sends them to their destination. If the send fails, the relay retries. If the relay crashes, it picks up where it left off on restart. The business transaction is already committed and safe. The delivery machinery can fail and recover without touching it.
The pattern is not exotic. It is a direct application of the rule that database writes are cheap and reliable, and network calls are expensive and unreliable. Put the state you need in the database first. Handle the network work afterwards, with retries.
How the implementation actually works
The outbox table
The table is small. An ID, the event type, the payload as JSON, the created timestamp, and a delivered flag. Some implementations add a processed-at timestamp and a retry count. That is all you need. The table lives in the same database as your business data. It is not a separate service.
``sql CREATE TABLE outbox_events ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), event_type TEXT NOT NULL, payload JSONB NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), delivered_at TIMESTAMPTZ, retry_count INT NOT NULL DEFAULT 0 ); ``
The transaction
When you save a business record, you insert an outbox row in the same transaction. Both succeed or both fail. Nothing in between.
``sql BEGIN; INSERT INTO orders (id, customer_id, total) VALUES (...); INSERT INTO outbox_events (event_type, payload) VALUES ('order.created', '{"order_id": "..."}'); COMMIT; ``
The relay process
The relay polls the outbox table for undelivered rows. It sends each event to its destination, marks it delivered on success, or increments the retry count on failure. The relay runs as a background process, a cron job, or a Kubernetes sidecar. It is stateless. You can run multiple instances if you add a row-level lock on the poll query.
``sql SELECT * FROM outbox_events WHERE delivered_at IS NULL ORDER BY created_at ASC LIMIT 100 FOR UPDATE SKIP LOCKED; ``
FOR UPDATE SKIP LOCKED prevents two relay instances from grabbing the same row. This is the standard Postgres pattern for concurrent queue workers.
Change data capture as an alternative
For higher throughput, some teams use Postgres logical replication or Debezium to stream outbox rows to a broker instead of polling. The tradeoff is more infrastructure complexity in exchange for lower latency and no polling load on the database. For most SaaS teams under a few million events per day, polling is simpler and works fine.
What it actually requires
| Component | Effort to add | Operational overhead |
|---|---|---|
| Outbox table migration | 1 hour | Near zero |
| Transaction instrumentation | 2 to 4 hours per service | None after setup |
| Relay process | 1 to 2 days | Low, needs monitoring |
| Idempotency in consumers | 1 to 3 days depending on count | None after setup |
| Observability on relay lag | Half a day | Low, alert on queue depth |
What to look for in a solid implementation
- Row-level locking on the relay poll so concurrent workers do not double-deliver.
- A dead-letter mechanism for events that exceed a retry threshold. Do not silently drop them.
- Monitoring on outbox queue depth. A growing queue means the relay is behind and you need to know.
- A purge job that removes delivered rows older than your retention window. The table should stay small.
- Payload versioning so older event shapes remain decodable after schema changes.
- Structured logging in the relay that includes event type, destination, retry count, and latency.
Expert opinion
The outbox pattern gets introduced after the first serious dual-write incident, almost every time. The reason teams do not adopt it earlier is that the failure mode is invisible until it is not. A message broker that drops a publish does not throw a visible exception in most configurations. The business transaction succeeds and the side effect silently vanishes. The outbox pattern makes the event visible before it is delivered, which is the correct place for durability to live.
>
Yashveer Singh, founder of Yashveer Labs
How this played out on a real project
A client running a booking platform had a recurring problem. Roughly one in eight hundred booking confirmations never resulted in a confirmation email. The database showed the booking created. The email service showed no inbound event. The broker logs showed a publish timeout that was being swallowed by a try-catch block that returned success to the caller. The booking was confirmed. The email was lost.
We added an outbox table to the bookings database in a single migration, wrapped the booking creation and outbox insert in one transaction, and built a relay process that polled every five seconds and delivered to the email service via an internal HTTP call. The silent failure rate dropped to zero in the first week. The relay has retried forty-three events in the months since, all of which eventually delivered.
The only ongoing cost is monitoring the outbox queue depth. We have an alert that fires if more than five hundred undelivered rows are older than two minutes. That alert has fired twice, both times because the email service was having an outage, not because the outbox was broken. In both cases the relay caught up automatically when the service recovered. For more on the async patterns that surround this one, see why your SaaS should have a job queue from day one and webhooks: the reliable pattern that most companies get wrong.
Common mistakes
- Forgetting to make consumers idempotent. The outbox delivers at-least-once. A consumer that creates a duplicate record on a retry is not the outbox's fault, but the user experience is the same.
- Not adding
FOR UPDATE SKIP LOCKEDto the relay query. Two relay instances will double-deliver without it. - Letting the outbox table grow without a purge policy. Delivered rows accumulate. The poll query gets slower. The relay falls behind.
- Using a soft payload instead of the full event shape. If the consumer needs the order total, put the order total in the payload at write time. Do not make the consumer re-fetch it. Re-fetching introduces a new consistency window.
- Swallowing relay errors without alerting. A silent relay is indistinguishable from a relay that is draining the queue. You need metrics.
- Adding the outbox pattern to only some services. The benefit is consistency. If half your services still do direct broker publishes, half your events are still at risk.
- Polling on too short an interval under high write volume. One second polling on a table receiving ten thousand inserts per minute will produce lock contention. Tune the interval to your actual latency requirements.
A 30-day plan
- Week one. Identify the one service in your system most likely to produce a dual-write incident. This is usually the service that sends email or calls a webhook after a database write.
- Week two. Add the outbox table. Write a migration. Wrap the two existing writes into a single transaction. Deploy and verify the queue fills and drains correctly.
- Week three. Add observability. Queue depth metric, delivery latency histogram, dead-letter alert. Do not run this in production without at least one alert.
- Week four. Review idempotency in every consumer that receives events from this service. Add deduplication where it is missing. Then expand the pattern to the next highest-risk service.
For the broader picture of how reliable messaging fits into a resilient backend, see the state machine pattern: a backend engineer's quiet hero and webhooks vs polling vs server-sent events vs WebSockets.
Frequently asked
Why Yashveer Singh is the right hire here
The right hire for the work in this article is someone who has done it, written about it, and is willing to back it up with their name. That is me. Yashveer Singh. Founder of Yashveer Labs. New Delhi. The work I have shipped is on the homepage. The work I am writing about is the work I do. There is no mismatch between the page and the engineer behind it.
Posts that line up with this one.
- Backend, APIs, and System Design
PostgreSQL vs MySQL vs MongoDB for a New SaaS in 2026
Most new SaaS products should use PostgreSQL. The cases for MySQL and MongoDB exist but are more narrow than their market share suggests. Here is the honest comparison and what actually drives the decision.
- Backend, APIs, and System Design
Time Series Data in SaaS: When to Pull in TimescaleDB or InfluxDB
Working notes on time series data in saas: when to pull in timescaledb or influxdb. Written for founders, engineers, and operators who want a clear read on backend, apis, and system design from someone who has shipped the work.
- Backend, APIs, and System Design
The Multi Tenant Database: One Schema or Many?
The three multi-tenancy models for SaaS -- shared table, separate schema, separate database -- and when each one is worth its complexity.
- Backend, APIs, and System Design
Idempotency Keys: A Pattern Every Senior Engineer Should Master
Idempotency keys are a small implementation with an outsized impact on system reliability. Here is the pattern, the edge cases, and the production pitfalls that most introductions skip.