A job queue on PostgreSQL with SKIP LOCKED
How several workers can claim jobs from a table without blocking each other, how leases and retries make it reliable, and when to move to a real broker.
On the roadmap:Intermediate · step 4: Messaging
Lesson 3 of 6 · 11 min read · quiz · exercise
Why a queue in your database
A broker such as Kafka or SQS is the right tool for many systems. But if you already run PostgreSQL and your volume is modest (hundreds to a few thousand jobs per second), a jobs table can be simpler and safer:
- You can enqueue in the same transaction as the business change. The job exists if and only if the change committed, so there is no gap between "saved the order" and "published the message".
- You can inspect, retry and delete jobs with plain SQL.
- One less system to run.
The claim problem
Many workers poll the same table. If each does SELECT ... LIMIT 1 FOR UPDATE, they all try to lock the same top row and wait for each other. FOR UPDATE SKIP LOCKED solves this: rows already locked by another worker are skipped, so each worker gets different rows.
WITH next AS (
SELECT id FROM jobs
WHERE status = 'queued' AND run_at <= now()
ORDER BY run_at
FOR UPDATE SKIP LOCKED
LIMIT 10
)
UPDATE jobs SET status = 'running', locked_until = now() + interval '5 minutes', attempts = attempts + 1
FROM next WHERE jobs.id = next.id
RETURNING jobs.*;
Figure SDA-SQR-03-1 · Workers claiming jobs without blocking each other · © forcsguy.com
Before: workers wait on locked rows
After: SKIP LOCKED: take a free row
What changed
- Workers never block each other
- Each job is claimed by exactly one worker
- Throughput grows with the number of workers
Making it reliable
- Leases. The claim sets
locked_until. If a worker dies, a reclaim query (or the claim itself, by also accepting running rows whose lease has expired) makes the job available again. Jobs must therefore be idempotent: they can run twice. - Retries with backoff. On failure set
run_at = now() + backoff(attempts), for example doubling each time with jitter. - Dead letters. After a maximum number of attempts, mark the job
deadand alert, so one poison job cannot loop forever. - Priorities and scheduling. Order by
priority, run_at; scheduled jobs are just a futurerun_at. - Index. A partial index on
(run_at) WHERE status = 'queued'keeps the claim query fast.
Figure SDA-SQR-03-2 · Job life cycle · © forcsguy.com
Operating it
- Table bloat. Frequently updated rows create dead tuples. Delete or archive finished jobs, tune autovacuum, and keep the hot table small (a separate history table).
- Polling vs notify. Poll every few hundred milliseconds, or use
LISTEN / NOTIFYto wake workers sooner. - Visibility. Track queue depth, age of the oldest job, and failure counts. Alert on age, not just depth.
- Ordering. SKIP LOCKED does not give strict ordering across workers; if ordering per entity matters, route by key or use a broker with partitions.
When to move on
Switch to a dedicated broker when throughput outgrows one database, many independent consumers need the same events (fan-out), you need long retention and replay, or the queue load hurts your main database. You can keep the outbox idea: write events in the same transaction, and a relay publishes them to the broker.
Real-world reading
Related reading
- PostgreSQL: the locking clause (FOR UPDATE SKIP LOCKED)PostgreSQL Global Development Group · PostgreSQLDocumentation
- What is SELECT SKIP LOCKED for in PostgreSQL?2ndQuadrant · 2ndQuadrant (EDB)Blogs
- Scaling Slack's Job QueueSlack Engineering · SlackCase Studies
- Scaling Job Queue with Redis at SlackSlack engineers · SlackCase Studies
Check your understanding
Question 1 of 3
0 correct so far
What does FOR UPDATE SKIP LOCKED do?
Go deeper
- SELECT: the locking clause by PostgreSQL: reference for FOR UPDATE and SKIP LOCKED
- What is SELECT SKIP LOCKED for in PostgreSQL? by 2ndQuadrant: motivation and examples
- Scaling Slack's job queue by Slack Engineering: a production story of queue design
Practise it: A job burst
Everything below is already set up for this lesson: change the design, run the traffic, and see whether you meet the goal.
- 1The task
- 2Build and test
A burst of jobs lands on your Postgres-backed queue.
Goal: Under 1% errors, queue clears within 30 s.
Callers do not need the write applied this very second. What can hold it?
Try it yourself
Design the jobs table and claim query for 5 workers sending emails. Handle crashes, retries with backoff and a poison job that always fails.
Click what a strong design here would need:
More in Building blocks
© 2026 forcsguy.com. Original lesson text and diagrams. Books and sites linked above belong to their authors.