Durable Work in PostgresPart 1
The claim loop
Minimal Postgres claim loop: enqueue, SKIP LOCKED claim, complete, and handler retry with an interactive simulation.
A setTimeout cannot outlive the process that created it. The customer has paid for order:9182, but the receipt still has to survive the gap between that commit and the outbound email. If the API process dies, the work dies with it, and support has no durable record to inspect.
The smallest durable design is a row that means “send the receipt for order:9182.” A background
worker changes that row from pending to processing and records its own identity. That atomic
state change is the claim: it lets several workers search the same table while ensuring only one of
them receives a particular row. The winning worker runs the email handler and marks the row
complete.
That repeated sequence is the claim loop: find available work, take temporary ownership, process it, and record the result. This first version covers enqueue, claim, and completion. It is not safe under a worker crash until the next article adds lease recovery and idempotency.
Persist the promise before running the work
This is a durable work queue. It starts like a standard background job queue: persist a unit of work, let competing consumers claim it, deliver at least once, and make handlers safe to run twice. The same core loop also supports outbox relays, projections, webhook processing, and saga steps.
Postgres is the queue and coordination layer.
The enqueue happens in the same ACID transaction as the business write, which means the order and
its receipt job are committed together or rolled back together. Claims use Postgres row locks
instead of a broker assigning messages. SKIP LOCKED tells each worker to ignore rows another
transaction is already claiming, so workers can take different jobs without waiting in a line
behind the same locked row. The remaining backlog stays visible to ordinary SQL when work appears
stalled.
| Postgres implementation | Established name | Typical source |
|---|---|---|
Queue table (inbox, outbox) | Message Channel | Enterprise Integration Patterns |
Many workers, SKIP LOCKED claim | Competing Consumers | Enterprise Integration Patterns |
partition_key plus hash ring | Partitioned consumption | EIP plus stream systems |
Lease plus claimed_by | Exclusive work ownership | Distributed systems |
idempotency_key, safe retries | Idempotent Receiver | Enterprise Integration Patterns |
dead_letter | Dead Letter Channel | Enterprise Integration Patterns |
| Outbox in the same transaction as domain write | Transactional Outbox | Microservices patterns |
What production asks of any queue
Before choosing a queue, write down the failures you must survive: a deploy loses jobs, a customer gets two emails, or a row sits in processing for an hour. The implementation needs an answer for each case.
- Accepted work must run: if the API returned success, the email, webhook, or ledger entry cannot disappear because a worker restarted mid-deploy.
- Ordering is explicit when required: if work for
order:9182must run in order, the claim query must prevent a later row from passing the oldest unfinished row. - Process affinity is optional: if a handler benefits from a local cache, fixed buckets and a consistent hash can keep most keys on the same worker while the pool changes.
- Retries must not duplicate side effects: workers crash, leases expire, and webhooks retry. Handlers must tolerate running twice.
- Stuck and poison rows are handled: a crashed worker cannot hold rows in
processingforever.
Minimal claim loop
Start with one table, one claim query, and one worker process in a loop. For order:9182, the row means send receipt after checkout.
Minimal inbox table
Open in StudioCREATE TABLE inbox (
id UUID PRIMARY KEY DEFAULT uuidv7(),
partition_key TEXT NOT NULL,
partition_bucket INT NOT NULL
CHECK (partition_bucket >= 0 AND partition_bucket < 1024),
payload JSONB NOT NULL,
status TEXT NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending', 'processing', 'completed', 'dead_letter')),
idempotency_key TEXT,
claimed_by TEXT,
claimed_at TIMESTAMPTZ,
lease_expires_at TIMESTAMPTZ,
lease_generation BIGINT NOT NULL DEFAULT 0,
attempts INT NOT NULL DEFAULT 0,
max_attempts INT NOT NULL DEFAULT 5,
available_at TIMESTAMPTZ NOT NULL DEFAULT now(),
completed_at TIMESTAMPTZ,
last_error TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX idx_inbox_idempotency
ON inbox (idempotency_key) WHERE idempotency_key IS NOT NULL;
Enqueue: producer writes pending
Your API inserts a row in the same transaction as the business write when you can. For order:9182, the payload tells the worker what to run later.
INSERT INTO inbox (partition_key, partition_bucket, payload, idempotency_key)
VALUES (
'order:9182',
417,
'{"type":"send_receipt","order_id":9182}'::jsonb,
'receipt-9182-v1'
);
Claim: worker takes ownership
Claiming sets status = 'processing', stamps claimed_by, and sets lease_expires_at for crash recovery. Each claim bumps lease_generation so only the current owner can call complete.
WITH picked AS (
SELECT id FROM inbox
WHERE status = 'pending' AND available_at <= now()
ORDER BY created_at, id
LIMIT 25
FOR UPDATE SKIP LOCKED
)
UPDATE inbox i
SET status = 'processing',
claimed_by = $1,
claimed_at = now(),
lease_expires_at = now() + interval '90 seconds',
lease_generation = lease_generation + 1,
attempts = attempts + 1
FROM picked WHERE i.id = picked.id
RETURNING i.*;
Complete or retry on handler outcome
UPDATE inbox
SET status = 'completed',
completed_at = now(),
claimed_by = NULL,
claimed_at = NULL,
lease_expires_at = NULL
WHERE id = $1
AND claimed_by = $2
AND lease_generation = $3
AND lease_expires_at > now()
AND status = 'processing';
UPDATE inbox
SET
status = 'pending',
claimed_by = NULL,
claimed_at = NULL,
lease_expires_at = NULL,
last_error = $4,
available_at = now() + (LEAST(POWER(2, attempts)::int, 3600) * interval '1 second')
WHERE id = $1
AND claimed_by = $2
AND lease_generation = $3
AND status = 'processing';
The worker id plus lease_generation are a fence token. If rowCount is zero, this worker lost ownership and must not assume the side effect is complete.
Wire the loop in your language
db is a small inbox repository: each method runs one of the SQL snippets above against a Postgres connection pool.
const WORKER_ID = `${hostname}-${process.pid}`;
async function run() {
while (true) {
const batch = await db.claim(WORKER_ID);
if (batch.length === 0) {
await sleep(500);
continue;
}
for (const row of batch) {
try {
await handle(row);
await db.complete(row.id, WORKER_ID, row.lease_generation);
} catch (err) {
await db.failWithRetry(row.id, WORKER_ID, row.lease_generation, err);
}
}
}
}
Where this breaks down
At-least-once delivery is honest only when downstream effects are safe to repeat.
I keep this loop small until one of these constraints becomes real:
- Hot polling: a 50ms idle loop on an empty queue will waste CPU and connection time. Back off or use
LISTEN. - Process death without lease cleanup: rows stuck in
processingneed leases and a sweeper that returns expired claims topending. - Exactly-once claims: this is at-least-once with guards. Do not promise finance-grade exactly-once without idempotent downstream stores.
Source
These companion files collect the SQL and TypeScript used in this article. The link labels distinguish full implementations from focused examples and worker skeletons.