Postgres as a Job Queue: FOR UPDATE SKIP LOCKED Done Properly
You may not need Redis or a broker for background jobs. How SKIP LOCKED makes Postgres a safe concurrent queue: claim/lease/ack, visibility timeouts and crash recovery, retries with backoff, LISTEN/NOTIFY, transactional enqueue, indexing, bloat, and its limits.
Adding a message broker for background jobs means another service to run, secure, back up, and keep consistent with your database. PostgreSQL can serve as a correct, concurrent job queue for a large range of workloads — the core primitive is SELECT … FOR UPDATE SKIP LOCKED, available since 9.5. Mature libraries build on it (pg-boss and Graphile Worker for Node, River for Go, Oban for Elixir, Solid Queue for Rails, Procrastinate for Python); this article covers the mechanics so you can evaluate or build one. (Your App Needs Background Jobs covers the why.)
The core idea
Many workers poll the same table. Without coordination, they'd all grab the same job. FOR UPDATE locks the rows a worker selects; SKIP LOCKED makes other workers skip rows that are already locked instead of waiting for them. Each worker gets a disjoint set of jobs, with no blocking.
Schema
CREATE TABLE jobs (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
queue text NOT NULL DEFAULT 'default',
kind text NOT NULL,
payload jsonb NOT NULL,
status text NOT NULL DEFAULT 'ready'
CHECK (status IN ('ready','running','done','failed')),
priority smallint NOT NULL DEFAULT 0,
run_at timestamptz NOT NULL DEFAULT now(),
attempts int NOT NULL DEFAULT 0,
max_attempts int NOT NULL DEFAULT 20,
locked_by text,
locked_until timestamptz,
last_error text,
unique_key text,
created_at timestamptz NOT NULL DEFAULT now()
);
-- The claim query's index: only rows that could be claimed
CREATE INDEX jobs_claim_idx ON jobs (queue, priority DESC, run_at, id)
WHERE status = 'ready';
-- Lease recovery
CREATE INDEX jobs_running_idx ON jobs (locked_until) WHERE status = 'running';
-- Optional de-duplication of pending work
CREATE UNIQUE INDEX jobs_unique_pending ON jobs (unique_key)
WHERE unique_key IS NOT NULL AND status IN ('ready','running');
Claim with a lease, not a long transaction
The naive pattern holds a transaction (and the row lock) open for the whole job. That ties up a connection per in-flight job, and a long-running transaction holds back vacuum across the whole database. Prefer short transactions that claim a lease:
WITH next AS (
SELECT id FROM jobs
WHERE queue = $1 AND status = 'ready' AND run_at <= now()
ORDER BY priority DESC, run_at, id
LIMIT $2
FOR UPDATE SKIP LOCKED
)
UPDATE jobs j
SET status = 'running',
attempts = j.attempts + 1,
locked_by = $3,
locked_until = now() + make_interval(secs => $4)
FROM next
WHERE j.id = next.id
RETURNING j.*;
The row locks exist only for this statement's transaction. The lease (locked_until) is what protects the job afterwards.
Ack, retry, or fail
-- success
UPDATE jobs SET status = 'done', locked_by = NULL, locked_until = NULL
WHERE id = $1 AND locked_by = $2;
-- failure: exponential backoff with jitter, or give up
UPDATE jobs
SET status = CASE WHEN attempts >= max_attempts THEN 'failed' ELSE 'ready' END,
run_at = now() + make_interval(secs => least(3600, power(2, attempts)) * (0.5 + random())),
last_error = $3, locked_by = NULL, locked_until = NULL
WHERE id = $1 AND locked_by = $2;
The locked_by = $2 guard is a fencing check: if this worker's lease expired and another worker reclaimed the job, the stale worker's ack updates zero rows and it knows its result is void.
Recover crashed workers
UPDATE jobs SET status = 'ready', locked_by = NULL, locked_until = NULL
WHERE status = 'running' AND locked_until < now();
Run it periodically (or fold the condition into the claim query). Long jobs heartbeat by extending locked_until. Choose lease durations well above normal job duration, and accept the consequence: this is at-least-once delivery. A worker can finish the work, then die before acking, and the job runs again. Handlers must be idempotent. (Handling Webhooks Reliably covers idempotency techniques.)
Wake workers with LISTEN/NOTIFY
Polling every few hundred milliseconds adds latency and load. Notify on enqueue:
-- enqueue
INSERT INTO jobs (queue, kind, payload) VALUES ('email', 'welcome', $1);
SELECT pg_notify('jobs_email', '');
Workers LISTEN jobs_email and claim on notification, plus a slower fallback poll (notifications are lost if nobody is listening, and they're delivered only at commit). Notes: LISTEN needs a dedicated session connection — it doesn't work through a transaction-mode pooler; payloads are limited to 8000 bytes (send nothing, or an ID); and a very high notify rate has its own overhead, so coalesce.
The killer feature: transactional enqueue
Because the queue is in the same database, you can enqueue atomically with your business write:
BEGIN;
INSERT INTO orders (...) VALUES (...) RETURNING id;
INSERT INTO jobs (kind, payload) VALUES ('send_receipt', jsonb_build_object('order_id', ...));
COMMIT;
Either both happen or neither. With an external broker, you'd face the dual-write problem — commit succeeds but the publish fails, or vice versa. (The Transactional Outbox Pattern generalises this.)
Operational concerns
Bloat. A queue table is high-churn: every job is inserted, updated several times, and eventually removed. Dead tuples accumulate; the claim index degrades. Mitigations:
- Delete or archive
donejobs promptly (or move them to a partitioned history table and drop partitions). - Tune autovacuum per table (
ALTER TABLE jobs SET (autovacuum_vacuum_scale_factor = 0.01, …)). - Watch
n_dead_tupinpg_stat_user_tables. - Avoid long-running transactions anywhere in the database — they prevent cleanup of dead rows for every table, and the queue suffers first.
Contention. SKIP LOCKED scans past locked rows; with very many workers claiming from the head of the same queue, they can burn CPU skipping each other. Claim in batches (LIMIT 10), shard by queue, and keep the partial index tight.
Ordering. Priority and run_at order is best-effort under concurrency and retries. If you need strict per-entity ordering (all jobs for account 42 in sequence), add a per-key lock (e.g. pg_try_advisory_xact_lock(hashtext(key))) or a "one running job per key" constraint.
Observability. Queue depth per queue, oldest ready job age, failure rate, retry counts, and lease expirations. Alert on age, not just depth.
Where it stops being the right tool
Postgres queues comfortably handle many small-to-medium workloads — commonly into the hundreds or low thousands of jobs per second on decent hardware, depending on job size and tuning. Consider a dedicated broker when you need:
- Sustained very high throughput where queue churn competes with your primary workload.
- Fan-out pub/sub to many independent consumers, or replayable event streams (Kafka-style).
- Cross-service messaging where services don't share a database.
Until then, one database with transactional enqueue is simpler, and simplicity is operational reliability.
EasySpawn runs your app, its workers, and a managed Postgres in one persistent workspace — so a Postgres-backed queue needs no extra infrastructure, and Claude Code can load-test claim throughput against the real database. See how it works or join the waitlist.
Related: Database Transactions Explained · Postgres Connection Pooling · Redis: When You Actually Need It
Keep reading
The Transactional Outbox Pattern: Reliable Events Without Dual Writes
Writing to your database and publishing an event can't be made atomic, so one eventually happens without the other. How the transactional outbox fixes it: polling relays vs CDC, ordering, at-least-once delivery, idempotent consumers with an inbox, cleanup, and monitoring.
Postgres Point-in-Time Recovery: WAL Archiving, Base Backups, and Restore Drills
A nightly dump can lose a day of data. Point-in-time recovery restores to the second before the bad migration. How WAL archiving and base backups combine, the settings that matter, recovery targets and timelines, pgBackRest and WAL-G, and restore drills.