All ArticlesTemplates•October 28, 2026•10 min read

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.

High-tech server rack in a secure data center with network cables and hardware components.Photo by Sergei Starostin on Pexels

Why the Dual-Write Problem Quietly Breaks Event-Driven Systems

The first version of almost every event-driven service looks like this:

ts
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:

  1. Lost event. The INSERT commits, then the process crashes (OOM kill, spot instance reclaim, serverless timeout) before publish. The order exists, but inventory never reserves stock and the customer never gets an email. Nothing in your system knows an event is missing.
  2. Phantom event. publish succeeds, then the transaction rolls back or the connection dies. Consumers receive an order.placed for a row that does not exist, and their SELECT returns zero rows.
  3. Duplicate event. publish is 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

sql
-- 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

ts
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.

sql
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;
ts
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:

sql
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)
);
ts
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:

ts
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

StrategyLatencyOps costOrderingBest fit
Polling relay + SKIP LOCKED100 ms–5 sLowStrong by idDefault; works everywhere
CDC (Debezium / pgoutput)~10–50 msHighPer-partitionHigh volume, event streaming
pg_notify + LISTEN wakeupSub-secondLowNoneAdd-on to polling, not a replacement
Postgres-native queue (pgmq)LowLowConfigurablePostgres-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

sql
-- 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-version header 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 duplicate status.
  • 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 DELETE on a hot table causes lock and WAL spikes.

Sources & Technical References

0 views•0 comments

Discussion (0)

Join the conversation

Loading comments...

Continue Reading

More production-grade engineering deep dives and architectural guides

Explore all articles