Event-Driven SaaS with the Outbox Pattern: Reliable Events Without Dual Writes
Publishing domain events directly from app code often causes lost messages and inconsistent state. Learn how to implement the outbox pattern in a SaaS architecture with practical TypeScript examples, delivery workers, retries, and observability.
If your SaaS app writes to a database and then publishes a message to a queue in the same request, you are one partial failure away from data inconsistency.
This is the classic dual-write problem:
- You save
invoice.status = paidin your database. - Then you publish
invoice.paidto your broker. - The broker call fails (timeout, network blip, deploy restart).
Now your source of truth says “paid,” but downstream systems never get notified. Finance, analytics, and notification pipelines drift out of sync.
The outbox pattern is the most practical fix. It gives you reliable event publishing without distributed transactions.
Why Distributed Transactions Usually Aren’t the Answer
In theory, two-phase commit can make DB + broker updates atomic. In practice, it adds coupling, latency, and operational complexity across systems that may not even support the same transaction model.
Most production SaaS teams need something simpler:
- Strong consistency in the primary datastore
- Eventually consistent delivery to other systems
- Safe retries and observability
That is exactly what the outbox pattern provides.
The Core Idea
Instead of publishing directly to Kafka/SQS/NATS in your request handler, you write an event record to an outbox table in the same database transaction as your domain change.
Later, a background worker reads pending outbox rows and publishes them.
If the publish fails, the row stays pending and is retried.
Data Flow
- API request updates business data.
- Same DB transaction inserts outbox record.
- Worker polls/claims outbox rows.
- Worker publishes message.
- Worker marks row as sent (or schedules retry).
No dual write. Either both domain change + outbox entry commit, or neither do.
Minimal Schema
create table outbox_events (
id uuid primary key,
aggregate_type text not null,
aggregate_id text not null,
event_type text not null,
payload jsonb not null,
occurred_at timestamptz not null default now(),
status text not null default 'pending', -- pending | sent | failed
attempts int not null default 0,
next_attempt_at timestamptz not null default now(),
sent_at timestamptz,
last_error text
);
create index outbox_pending_idx
on outbox_events (status, next_attempt_at)
where status = 'pending';
Application Write Path (TypeScript)
import { randomUUID } from "node:crypto";
async function markInvoicePaid(db: DbClient, invoiceId: string, paidAt: string) {
await db.transaction(async (tx) => {
await tx.query(
`update invoices set status = 'paid', paid_at = $1 where id = $2`,
[paidAt, invoiceId]
);
await tx.query(
`insert into outbox_events
(id, aggregate_type, aggregate_id, event_type, payload)
values ($1, $2, $3, $4, $5::jsonb)`,
[
randomUUID(),
"invoice",
invoiceId,
"invoice.paid",
JSON.stringify({ invoiceId, paidAt }),
]
);
});
}
The transaction boundary is the guarantee. If this transaction commits, your event is durably queued for publishing.
Publisher Worker
A worker continuously claims due rows and publishes them.
const BATCH_SIZE = 100;
async function claimPendingEvents(db: DbClient) {
return db.query(
`with cte as (
select id
from outbox_events
where status = 'pending'
and next_attempt_at <= now()
order by occurred_at
limit $1
for update skip locked
)
update outbox_events o
set attempts = attempts + 1
from cte
where o.id = cte.id
returning o.*`,
[BATCH_SIZE]
);
}
function backoffSeconds(attempt: number) {
return Math.min(300, Math.pow(2, attempt));
}
async function processEvent(db: DbClient, broker: Broker, e: OutboxEvent) {
try {
await broker.publish(e.event_type, e.payload, {
messageId: e.id, // useful for downstream idempotency
occurredAt: e.occurred_at,
});
await db.query(
`update outbox_events
set status = 'sent', sent_at = now(), last_error = null
where id = $1`,
[e.id]
);
} catch (err) {
await db.query(
`update outbox_events
set next_attempt_at = now() + ($2 || ' seconds')::interval,
last_error = $3,
status = case when attempts >= 12 then 'failed' else 'pending' end
where id = $1`,
[e.id, backoffSeconds(e.attempts), String(err)]
);
}
}
Important details:
FOR UPDATE SKIP LOCKEDenables safe parallel workers.- Exponential backoff prevents hot-loop retries.
- Capped attempts move poison messages to
failedfor manual handling.
Consumer Idempotency Is Still Required
The outbox pattern gives reliable at-least-once delivery, not exactly-once. Your consumers must deduplicate.
A simple approach:
- Include stable event ID in every message
- Store processed IDs in consumer DB
- Ignore duplicates when ID already exists
This protects you from retries and broker redeliveries.
Operational Guardrails
The pattern is easy to implement but easy to under-operate. Add these from day one:
- Lag metric: oldest pending event age (
now - min(occurred_at)). - Throughput metric: events sent per minute.
- Failure rate: failed publishes / total publishes.
- Dead-letter visibility: dashboard for
status = failed. - Alerting: page when lag exceeds your SLO.
Without these, teams discover broken pipelines from customer tickets instead of monitoring.
Polling vs CDC
You can publish outbox rows in two common ways:
-
Polling worker (shown above)
- Easiest to build
- Works everywhere
- Slightly higher latency
-
CDC (change data capture) with tools like Debezium
- Lower latency and less custom worker code
- More infra complexity
- Best when event volume is high
For most teams, start with polling and upgrade to CDC only when needed.
Common Mistakes
- Publishing in request path anyway
- Undermines the whole pattern.
- No retry scheduling
- Creates permanent data loss during transient outages.
- No dead-letter handling
- Poison messages disappear silently.
- No consumer idempotency
- Duplicates cause side effects.
- Never deleting sent rows
- Outbox table grows forever.
Add periodic retention jobs (for example, delete sent rows older than 30 days or archive them).
When the Outbox Pattern Is a Great Fit
Use it when:
- Your app is database-centric
- You need reliable event propagation to multiple systems
- You can tolerate eventual consistency
- You want simple, auditable failure recovery
If this describes your SaaS platform, the outbox pattern is usually the highest-leverage reliability upgrade you can ship in a sprint.
Reliable eventing is less about exotic infrastructure and more about disciplined transaction boundaries. The outbox pattern turns “best effort” messaging into a system you can trust.
More in System Design
How Amazon Aurora Works Internally: The Log-Is-the-Database Architecture, Quorum Writes, and Storage-Compute Separation Behind Cloud-Native SQL
A deep dive into Aurora's storage-compute separation, the log-is-the-database design that pushes redo log processing to storage nodes, quorum-based replication across 6 copies in 3 AZs, protection group architecture, fast cloning, Serverless v2 scaling mechanics, and honest tradeoffs versus RDS, self-managed PostgreSQL, CockroachDB, and Cloud Spanner.
How CockroachDB Works Internally: Distributed SQL, Raft Consensus Per Range, and the Architecture Behind Serializable Transactions at Global Scale
A deep dive into CockroachDB's internals: the 512MB range-based data model with automatic splitting, per-range Raft replication, MVCC timestamp ordering with hybrid logical clocks, DistSQL physical planning, serializable transactions with timestamp refreshes, closed timestamps for follower reads, online schema changes using multi-version schema descriptors, and production considerations for hotspots and write amplification.
How Container Runtimes Work Internally: Namespaces, Cgroups, and the OCI Stack From Docker Run to Process Isolation
Containers are not virtual machines and they are not magic. They are a thin composition of Linux kernel primitives: namespaces, cgroups, and a layered filesystem. This article traces exactly what happens between docker run and a running process, covering the OCI spec, containerd, runc, overlay filesystems, and container networking at the kernel level.
How YugabyteDB Works Internally: DocDB Storage, Tablet Splitting, and the Dual API Architecture That Scales PostgreSQL Horizontally
A deep dive into YugabyteDB internals covering the DocDB storage engine built on RocksDB LSM trees, per-tablet Raft consensus, YSQL and YCQL dual query layers, distributed MVCC with hybrid logical clocks, automatic tablet splitting and rebalancing, xCluster multi-region replication, and production considerations for hotspots, connection pooling, and schema design.