Building Resilient Event-Driven Architectures with Postgres and Serverless Queues
Stop losing events in the gap between your database and your message queue. This master-class builds a production-grade transactional outbox on Postgres, a SKIP LOCKED relay with leases and backoff, and idempotent consumers wired to a serverless queue like QStash.
Photo by Sergei Starostin on PexelsWhy the Dual-Write Problem Quietly Breaks Event-Driven Systems
The first version of almost every event-driven service looks like this:
await pool.query('INSERT INTO orders (id, tenant_id, total_cents) VALUES ($1, $2, $3)', [id, tenantId, cents]);
await queue.publish('order.placed', { orderId: id });This is the dual-write problem, and it has exactly three failure modes, all of which occur in production:
- Lost event. The
INSERTcommits, then the process crashes (OOM kill, spot instance reclaim, serverless timeout) beforepublish. The order exists, but inventory never reserves stock and the customer never gets an email. Nothing in your system knows an event is missing. - Phantom event.
publishsucceeds, then the transaction rolls back or the connection dies. Consumers receive anorder.placedfor a row that does not exist, and theirSELECTreturns zero rows. - Duplicate event.
publishis slow, the client times out, your retry wrapper fires again. The queue now holds two copies.
Application-level retries cannot fix mode 1 because the crash window sits between two independent I/O operations on two independent systems. Two-phase commit and XA are theoretically correct but are effectively unavailable across a managed Postgres instance and a serverless queue: they block, they degrade availability under partition, and no managed queue vendor exposes an XA resource manager. This is precisely the boundary Pat Helland describes in Life Beyond Distributed Transactions — you do not get atomicity across services, so you get idempotence and at-least-once delivery instead.
The transactional outbox pattern removes the window entirely. The event becomes a row in the same Postgres database, written in the same ACID transaction as the business state change. One COMMIT means both the order and the intent to publish it are durable. A separate relay process then converts committed rows into queue messages.
Modeling the Outbox Table in PostgreSQL
-- migrations/0001_outbox.sql
CREATE TABLE outbox (
id bigserial PRIMARY KEY,
tenant_id uuid NOT NULL,
aggregate_type text NOT NULL,
aggregate_id text NOT NULL,
event_type text NOT NULL,
payload jsonb NOT NULL,
headers jsonb NOT NULL DEFAULT '{}'::jsonb,
idempotency_key text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
available_at timestamptz NOT NULL DEFAULT now(),
locked_until timestamptz,
attempt_count integer NOT NULL DEFAULT 0,
published_at timestamptz,
dead_lettered_at timestamptz,
last_error text,
CONSTRAINT outbox_idempotency_key_key UNIQUE (idempotency_key)
);
-- The only index the relay's claim query needs. Partial => stays small.
CREATE INDEX outbox_ready_idx
ON outbox (available_at, id)
WHERE published_at IS NULL AND dead_lettered_at IS NULL;Design notes that matter at scale:
- `bigserial` gives you a monotonic insertion order for free, which is what the relay sorts on. Sequence gaps from rolled-back transactions are harmless. If you shard writes across databases, switch to UUIDv7 or a Snowflake-style ID — never random UUIDv4, which destroys index locality.
- `available_at` + `attempt_count` encode retry state in the table, not in memory. The relay is stateless and restartable.
- `locked_until` is a lease, not a lock. It lets a crashed relay's in-flight rows be reclaimed automatically after the lease expires.
- `idempotency_key` is UNIQUE, so a retried business transaction (the classic double-click on a checkout button) cannot enqueue the same event twice.
- `dead_lettered_at` parks poison messages instead of letting them spin forever at the head of the queue.
Writing State and Events in a Single Transaction
import type { Pool, PoolClient } from 'pg';
export type JsonObject = Record<string, unknown>;
export interface OutboxRow<T extends JsonObject = JsonObject> {
readonly id: string;
readonly tenant_id: string;
readonly aggregate_type: string;
readonly aggregate_id: string;
readonly event_type: string;
readonly payload: T;
readonly headers: Record<string, string>;
readonly idempotency_key: string;
readonly attempt_count: number;
}
export async function withTransaction<T>(
pool: Pool,
fn: (tx: PoolClient) => Promise<T>,
): Promise<T> {
const tx = await pool.connect();
try {
await tx.query('BEGIN');
const result = await fn(tx);
await tx.query('COMMIT');
return result;
} catch (err) {
await tx.query('ROLLBACK').catch(() => undefined);
throw err;
} finally {
tx.release();
}
}
export interface NewEvent<T extends JsonObject> {
tenantId: string;
aggregateType: string;
aggregateId: string;
eventType: string;
payload: T;
headers?: Record<string, string>;
idempotencyKey: string;
}
export async function enqueue<T extends JsonObject>(
tx: PoolClient,
event: NewEvent<T>,
): Promise<void> {
await tx.query(
`INSERT INTO outbox
(tenant_id, aggregate_type, aggregate_id, event_type, payload, headers, idempotency_key)
VALUES ($1, $2, $3, $4, $5::jsonb, $6::jsonb, $7)
ON CONFLICT (idempotency_key) DO NOTHING`,
[
event.tenantId,
event.aggregateType,
event.aggregateId,
event.eventType,
JSON.stringify(event.payload),
JSON.stringify(event.headers ?? {}),
event.idempotencyKey,
],
);
}
// --- call site: business state + event, atomically ---
export async function placeOrder(pool: Pool, orderId: string, tenantId: string, totalCents: number) {
await withTransaction(pool, async (tx) => {
await tx.query(
`INSERT INTO orders (id, tenant_id, total_cents, status) VALUES ($1, $2, $3, 'placed')`,
[orderId, tenantId, totalCents],
);
await enqueue(tx, {
tenantId,
aggregateType: 'order',
aggregateId: orderId,
eventType: 'order.placed',
payload: { orderId, totalCents, currency: 'USD' },
headers: { 'schema-version': '1' },
idempotencyKey: `order.placed:${orderId}`,
});
});
}Keep this transaction short. No HTTP calls inside it. The whole point is that the outbox row commits at the same instant as the domain row, then a relay takes over.
Relaying Outbox Rows to a Serverless Queue
The relay claims a batch, commits the claim, then publishes. The claim query uses FOR UPDATE SKIP LOCKED, which lets you run N relay replicas concurrently without them ever blocking on each other or double-claiming a row.
WITH claimed AS (
SELECT id
FROM outbox
WHERE published_at IS NULL
AND dead_lettered_at IS NULL
AND available_at <= now()
AND (locked_until IS NULL OR locked_until < now())
ORDER BY id
LIMIT $1
FOR UPDATE SKIP LOCKED
)
UPDATE outbox o
SET locked_until = now() + ($2 * interval '1 second'),
attempt_count = o.attempt_count + 1
FROM claimed
WHERE o.id = claimed.id
RETURNING o.id, o.tenant_id, o.aggregate_type, o.aggregate_id,
o.event_type, o.payload, o.headers, o.idempotency_key, o.attempt_count;import { Client as QStashClient } from '@upstash/qstash';
import type { Pool } from 'pg';
const CLAIM_SQL = /* the query above */;
function backoffSeconds(attempt: number): number {
const base = Math.min(300, 2 ** attempt * 2); // 4s, 8s, 16s ... capped at 5 min
return Math.round(base * (0.5 + Math.random() * 0.5)); // full jitter
}
async function publishOne(
pool: Pool,
qstash: QStashClient,
row: OutboxRow,
maxAttempts: number,
): Promise<'published' | 'retry' | 'dead'> {
try {
await qstash.publishJSON({
url: `${process.env.CONSUMER_BASE_URL}/api/events`,
body: {
id: row.id,
type: row.event_type,
aggregateType: row.aggregate_type,
aggregateId: row.aggregate_id,
tenantId: row.tenant_id,
payload: row.payload,
},
headers: { ...row.headers, 'x-event-id': String(row.id) },
retries: 3,
deduplicationId: row.idempotency_key,
});
await pool.query(
`UPDATE outbox SET published_at = now(), locked_until = NULL, last_error = NULL WHERE id = $1`,
[row.id],
);
return 'published';
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
const exhausted = row.attempt_count >= maxAttempts;
await pool.query(
`UPDATE outbox
SET locked_until = NULL,
available_at = now() + ($2 * interval '1 second'),
dead_lettered_at = CASE WHEN $3 THEN now() ELSE dead_lettered_at END,
last_error = $4
WHERE id = $1`,
[row.id, exhausted ? 0 : backoffSeconds(row.attempt_count), exhausted, message.slice(0, 2000)],
);
return exhausted ? 'dead' : 'retry';
}
}
interface RelayOptions { batchSize: number; leaseSeconds: number; maxAttempts: number }
export async function relayBatch(
pool: Pool,
qstash: QStashClient,
opts: RelayOptions,
): Promise<{ published: number; retried: number; dead: number }> {
const { rows } = await pool.query<OutboxRow>(CLAIM_SQL, [opts.batchSize, opts.leaseSeconds]);
const counts = { published: 0, retried: 0, dead: 0 };
const outcomes = await Promise.allSettled(
rows.map((row) => publishOne(pool, qstash, row, opts.maxAttempts)),
);
for (const o of outcomes) {
if (o.status === 'fulfilled') counts[o.value === 'published' ? 'published' : o.value === 'retry' ? 'retried' : 'dead']++;
}
return counts;
}Why leases beat long-held locks across HTTP
The tempting shortcut is to publish inside the claim transaction so the row is marked and released atomically. Do not do it. A transaction held open across an HTTP round trip to a third-party API pins a connection, holds row locks, and can exceed idle_in_transaction_session_timeout. Worse, a slow queue turns into database lock contention. Commit the claim first, publish outside the transaction, then mark published_at. The lease (locked_until) guarantees another relay cannot grab the row while you are mid-flight, and after the lease expires a crashed relay's work is automatically reclaimed. The cost is at-least-once delivery — which is fine, because you are about to make consumers idempotent.
Making Consumers Idempotent with an Inbox Table
At-least-once is only safe if processing is idempotent. Store the message ID in the same transaction as the side effect:
CREATE TABLE processed_messages (
consumer text NOT NULL,
message_id text NOT NULL,
processed_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (consumer, message_id)
);export async function consumeOnce(
pool: Pool,
consumer: string,
messageId: string,
work: (tx: PoolClient) => Promise<void>,
): Promise<'processed' | 'duplicate'> {
return withTransaction(pool, async (tx) => {
const { rowCount } = await tx.query(
`INSERT INTO processed_messages (consumer, message_id) VALUES ($1, $2)
ON CONFLICT DO NOTHING`,
[consumer, messageId],
);
if (rowCount === 0) return 'duplicate' as const;
await work(tx); // side effects commit atomically with the dedupe row
return 'processed' as const;
});
}Always verify the queue signature before touching the database:
import { Receiver } from '@upstash/qstash';
const receiver = new Receiver({
currentSigningKey: process.env.QSTASH_CURRENT_SIGNING_KEY!,
nextSigningKey: process.env.QSTASH_NEXT_SIGNING_KEY!,
});
export async function POST(req: Request): Promise<Response> {
const raw = await req.text();
const ok = await receiver
.verify({ signature: req.headers.get('upstash-signature') ?? '', body: raw })
.catch(() => false);
if (!ok) return new Response('invalid signature', { status: 401 });
const event = JSON.parse(raw) as { id: string; type: string; payload: JsonObject };
const status = await consumeOnce(pool, 'billing-service', event.id, async (tx) => {
await tx.query(`UPDATE invoices SET status = 'paid' WHERE order_id = $1`, [event.payload.orderId]);
});
return Response.json({ status }, { status: 200 }); // 200 for duplicates: stop retrying
}Ordering, Concurrency, and Backpressure Trade-offs
Per-aggregate ordering is the only ordering you can keep
A single relay with ORDER BY id and concurrency 1 gives global order but caps throughput. The practical compromise: partition by hashtext(aggregate_id) % N into N lanes, run one relay per lane, and use the queue's flow control to cap parallelism per aggregate key. Consumers then only need to serialize per aggregate_id, which is almost always what the domain actually requires.
Choosing a relay strategy
| Strategy | Latency | Ops cost | Ordering | Best fit |
|---|---|---|---|---|
Polling relay + SKIP LOCKED | 100 ms–5 s | Low | Strong by id | Default; works everywhere |
CDC (Debezium / pgoutput) | ~10–50 ms | High | Per-partition | High volume, event streaming |
pg_notify + LISTEN wakeup | Sub-second | Low | None | Add-on to polling, not a replacement |
| Postgres-native queue (pgmq) | Low | Low | Configurable | Postgres-only consumers |
NOTIFY is fire-and-forget: notifications are lost if no listener is connected and are not transactional across connections. It is an excellent latency accelerator bolted onto the polling relay, never the durability mechanism.
Production Runbook and Operational Checklist
-- Backlog, lag, and poison depth in one query
SELECT
count(*) FILTER (WHERE published_at IS NULL AND dead_lettered_at IS NULL) AS backlog,
coalesce(max(now() - created_at) FILTER (WHERE published_at IS NULL AND dead_lettered_at IS NULL),
interval '0') AS oldest_lag,
count(*) FILTER (WHERE dead_lettered_at IS NOT NULL) AS dead_lettered
FROM outbox;
-- Retention: prune in batches so you never hold a long lock
DELETE FROM outbox
WHERE ctid IN (
SELECT ctid FROM outbox
WHERE published_at < now() - interval '7 days'
ORDER BY id LIMIT 5000
);- Alert on `oldest_lag`, not row count. A backlog of 50k healthy rows is fine; a single row older than 60 seconds is an incident.
- Alert on `dead_lettered` > 0. Parked events need a human and a replay endpoint.
- Tune lease > p99 publish duration (e.g. 60 s lease for a 3 s p99), or you will double-publish under GC pauses.
- Run the relay as a dedicated worker with a connection pool sized to
relayConcurrency, not a serverless function per request. - Set `idle_in_transaction_session_timeout` so a stuck relay cannot hold locks forever.
- Version your payloads. The
schema-versionheader is the cheapest forward-compatibility insurance you will ever buy.
Pitfalls to Avoid
- Publishing inside the claim transaction. Guarantees lock contention and connection starvation the moment the queue slows down.
- Using `LISTEN/NOTIFY` as the source of truth. Notifications are dropped when the listener is reconnecting.
- Relying on the queue's deduplication as your only idempotency layer. Deduplication windows are time-bounded (QStash's is minutes); real retries can span days.
- Returning 5xx for duplicate deliveries. You will retry forever on an event you already processed. Return 200 with a
duplicatestatus. - Unbounded outbox growth. Without a pruning job, the partial index and the table both bloat and the claim query degrades.
- Ignoring `ctid`-based batch deletes. A single giant
DELETEon a hot table causes lock and WAL spikes.
Sources & Technical References
- PostgreSQL Global Development Group — SELECT, The Locking Clause (
FOR UPDATE SKIP LOCKED): https://www.postgresql.org/docs/current/sql-select.html - PostgreSQL Global Development Group — Logical Replication: https://www.postgresql.org/docs/current/logical-replication.html
- PostgreSQL Global Development Group — NOTIFY / LISTEN: https://www.postgresql.org/docs/current/sql-notify.html
- Chris Richardson — Pattern: Transactional Outbox (microservices.io): https://microservices.io/patterns/data/transactional-outbox.html
- Debezium — Outbox Event Router SMT: https://debezium.io/documentation/reference/stable/transformations/outbox-event-router.html
- Upstash — QStash Getting Started: https://upstash.com/docs/qstash/overall/getstarted
- Upstash — QStash Retries & Dead Letter Queues: https://upstash.com/docs/qstash/features/retry
- Upstash — QStash Signature Verification: https://upstash.com/docs/qstash/howto/signature
- Upstash — QStash Deduplication: https://upstash.com/docs/qstash/features/deduplication
- Brian Carlson — node-postgres Pool API: https://node-postgres.com/apis/pool
- Pat Helland — Life Beyond Distributed Transactions: An Apostate's Opinion, ACM Queue, 2016: https://queue.acm.org/detail.cfm?id=3025012
- Martin Kleppmann — Designing Data-Intensive Applications, O'Reilly Media, 2017 (Ch. 11, Stream Processing).
- AWS Builders' Library — Making Retries Safe with Idempotent APIs: https://aws.amazon.com/builders-library/making-retries-safe-with-idempotent-APIs/
Discussion (0)
Continue Reading
More production-grade engineering deep dives and architectural guides
Biggest Hack in the World: Binance Suffers Unprecedented $6.3 Billion Multi-Chain Cyberattack
A comprehensive technical autopsy of the $6.3B Binance hot-wallet breach attributed to TraderTraitor (Lazarus Group). How automated sweeping, compromised DevOps credentials, and cross-chain execution sparked the largest heist in financial history.

Mobile Push at 100M Devices: Benchmarking FCM, APNs, and Custom WebSockets
At 100M devices, push fails in the tail, not the median. We break down real delivery-latency percentiles for APNs, FCM, and owned WebSockets, plus a reproducible cost model and the reliability patterns behind 99.99%.

Landing Page Experimentation at 50M Sessions: Client-Side A/B Testing vs. Edge Decisioning vs. Server-Side Flags — Flicker, INP, and Cost per Million Decisions Benchmarked
At 50 million monthly sessions, where your A/B test makes its decision stops being a tooling preference and becomes a Core Web Vitals and P&L decision. This is a head-to-head benchmark of client-side DOM mutation, edge middleware bucketing, and server-side feature flags — measured on CLS flicker, INP, and true cost per million decisions.