TL;DR: A database-backed queue uses PostgreSQL (or MySQL) as a job queue — jobs are rows in a table, workers poll for and lock rows using SKIP LOCKED. Libraries like pg-boss handle all the complexity. Use this when you already have a database and don't want to operate Redis. Use Redis when you need high throughput or real-time speed.
Why AI Coders Need to Know This
Almost every real app needs background jobs at some point: send a welcome email after signup, resize an uploaded image, generate a PDF report, sync data to a third-party API. These tasks shouldn't block the HTTP response — you want to hand them off, return a "we're on it" to the user, and process them later.
When you ask AI to add a background job system, it almost always reaches for Redis + BullMQ (or Celery if you're on Python). That's the standard playbook. Redis is fast, BullMQ is battle-tested, and there are a thousand tutorials for it.
But here's the thing: Redis is a whole separate service. You need to run it, configure it, secure it, pay for it, and make sure it's available everywhere your app runs. For a side project processing a few hundred jobs a day, that's a lot of overhead for what is essentially "put a task in a list and process it later."
If you already have PostgreSQL, you have everything you need for a job queue. Database-backed queues use rows in a table as jobs, lock them during processing, and poll for new work. It's slower than Redis and less suitable for massive scale — but for most apps being built today, it's more than fast enough, and it's one less service to operate.
The Mental Model: A Construction Dispatch Board
Imagine a construction company dispatcher with a whiteboard. Every morning, new jobs get written on the board: "Install drywall at unit 12," "Plumbing inspection at unit 7," "Electrical rough-in at unit 3." Workers come in, grab a job off the board, write their name next to it, and go do it. When they finish, they erase the job.
Two rules keep it from becoming chaos:
- Once a worker claims a job, no one else touches it — you don't want two plumbers showing up to the same unit.
- If a worker goes home sick mid-job, the dispatcher marks that job as abandoned and puts it back on the board for someone else.
A database-backed queue is that dispatch board, except the whiteboard is a database table, each row is a job, workers claim rows by locking them, and the database's own transaction system enforces the "one worker per job" rule. The fancy SQL term for "skip jobs someone else already grabbed" is SKIP LOCKED.
Real Scenario: AI Hands You Redis, You Have Postgres
Prompt I Would Type
Add background job processing to my Node.js app. When a user
signs up, I want to send them a welcome email without blocking
the signup response. I already have PostgreSQL — use that
instead of Redis if possible.
That last sentence is key. Without it, AI defaults to Redis every time. With it, you'll get a pg-boss setup. Here's what a well-configured AI generates — and then we'll break down every piece.
What AI Generated
Step 1: Install pg-boss
npm install pg-boss
pg-boss is the most popular PostgreSQL-backed job queue for Node.js. It handles all the hard parts automatically: creating the jobs table, locking rows so two workers don't claim the same job, retrying failed jobs, expiring stale jobs, and scheduling recurring jobs.
Step 2: Initialize pg-boss
// lib/queue.js
import PgBoss from 'pg-boss';
// pg-boss connects to your existing PostgreSQL database
const boss = new PgBoss(process.env.DATABASE_URL);
// Handle errors so they don't crash the process
boss.on('error', (error) => console.error('pg-boss error:', error));
export default boss;
pg-boss takes your existing database connection string — the same one your app already uses — and creates a schema called pgboss with the tables it needs. No separate database, no new credentials, nothing to configure separately.
Step 3: Start the Queue and Register a Worker
// workers/emailWorker.js
import boss from '../lib/queue.js';
import { sendWelcomeEmail } from '../lib/email.js';
async function startEmailWorker() {
await boss.start();
// Register a handler for the 'send-welcome-email' queue
await boss.work('send-welcome-email', async (jobs) => {
for (const job of jobs) {
const { userId, email, name } = job.data;
try {
await sendWelcomeEmail({ email, name });
console.log(`Welcome email sent to ${email}`);
// pg-boss automatically marks the job as complete when the
// handler returns without throwing
} catch (error) {
// Throwing causes pg-boss to mark the job as failed
// and retry it based on your retry configuration
throw error;
}
}
});
console.log('Email worker started');
}
startEmailWorker();
The worker registers a handler for a named queue. When jobs arrive in that queue, pg-boss calls the handler with a batch of jobs. If the handler completes without throwing, the job is marked done. If it throws, the job is marked failed and retried.
Step 4: Enqueue Jobs from Your API
// routes/auth.js
import boss from '../lib/queue.js';
app.post('/api/signup', async (req, res) => {
const { email, name, password } = req.body;
// Create the user in the database
const user = await db.users.create({ email, name, password });
// Enqueue the welcome email job — this is near-instant
// The actual email is sent by the worker in the background
await boss.send('send-welcome-email', {
userId: user.id,
email: user.email,
name: user.name
});
// Respond immediately — don't wait for the email to send
res.status(201).json({
status: 'success',
message: 'Account created! Check your email.',
data: { userId: user.id }
});
});
The key insight is boss.send() just inserts a row into the jobs table — it's fast. The actual email sending happens in the background worker. Your API responds in milliseconds instead of waiting for the email provider to accept the message.
Step 5: Configure Retries and Options
// Send with retry options
await boss.send(
'send-welcome-email',
{ userId: user.id, email: user.email, name: user.name },
{
retryLimit: 3, // Retry up to 3 times on failure
retryDelay: 30, // Wait 30 seconds between retries
expireInHours: 24, // Abandon if not completed within 24 hours
priority: 0 // Higher numbers = higher priority
}
);
// Schedule a recurring job (like a daily digest email)
await boss.schedule(
'daily-digest',
'0 9 * * *', // Cron: every day at 9am
{}, // No specific data for recurring jobs
{ tz: 'America/New_York' }
);
Understanding SKIP LOCKED: The Secret Sauce
The hardest problem with any job queue is: how do you prevent two workers from processing the same job? With Redis and BullMQ, Redis itself handles this with atomic operations. With a database queue, you need a different mechanism.
Naive approach — don't do this:
-- ❌ RACE CONDITION — two workers can both select the same row
SELECT * FROM jobs WHERE status = 'pending' LIMIT 1;
UPDATE jobs SET status = 'processing' WHERE id = $1;
Between the SELECT and the UPDATE, another worker can read the same row. You now have two workers processing the same job. Classic race condition.
The right approach using SKIP LOCKED:
-- ✅ SAFE — each worker atomically locks and claims one job
-- No other worker can see rows that are locked
BEGIN;
SELECT * FROM jobs
WHERE status = 'pending'
ORDER BY created_at ASC
LIMIT 1
FOR UPDATE SKIP LOCKED; -- Lock the row; skip rows already locked
UPDATE jobs SET status = 'processing' WHERE id = $1;
COMMIT;
Here's what FOR UPDATE SKIP LOCKED does:
FOR UPDATE— lock the selected row so no other transaction can modify itSKIP LOCKED— if a row is already locked by another worker, skip it entirely instead of waiting
Think of it like a supermarket checkout line where instead of waiting for a slow cashier, customers are magically routed to the next open register. Each worker instantly gets an available job without blocking on jobs that are already being processed. pg-boss runs this exact SQL pattern internally — you don't have to write it yourself, but understanding it helps you debug when things go wrong.
The DIY Polling Pattern
If you don't want to add pg-boss as a dependency, you can build a basic queue yourself. It's a useful exercise to understand how database queues work under the hood.
Create the Jobs Table
CREATE TABLE jobs (
id BIGSERIAL PRIMARY KEY,
queue TEXT NOT NULL,
payload JSONB NOT NULL DEFAULT '{}',
status TEXT NOT NULL DEFAULT 'pending', -- pending, processing, done, failed
attempts INT NOT NULL DEFAULT 0,
max_attempts INT NOT NULL DEFAULT 3,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
run_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- for delayed jobs
locked_at TIMESTAMPTZ,
locked_by TEXT, -- worker ID for debugging
done_at TIMESTAMPTZ,
error TEXT
);
-- Index for fast polling: only look at pending/failed, only in the right queue
CREATE INDEX idx_jobs_queue_status ON jobs (queue, status, run_at)
WHERE status IN ('pending', 'failed');
The Worker Poll Loop
// Simple polling worker — checks every 5 seconds
const WORKER_ID = `worker-${process.pid}-${Date.now()}`;
const POLL_INTERVAL_MS = 5000;
async function pollAndProcess(queue, handler) {
while (true) {
const job = await claimNextJob(queue);
if (job) {
await processJob(job, handler);
} else {
// No jobs available — wait before polling again
await sleep(POLL_INTERVAL_MS);
}
}
}
async function claimNextJob(queue) {
const result = await db.query(`
UPDATE jobs
SET status = 'processing',
locked_at = NOW(),
locked_by = $1,
attempts = attempts + 1
WHERE id = (
SELECT id FROM jobs
WHERE queue = $2
AND status = 'pending'
AND run_at <= NOW()
ORDER BY run_at ASC
LIMIT 1
FOR UPDATE SKIP LOCKED
)
RETURNING *
`, [WORKER_ID, queue]);
return result.rows[0] || null;
}
async function processJob(job, handler) {
try {
await handler(job.payload);
await db.query(
`UPDATE jobs SET status = 'done', done_at = NOW() WHERE id = $1`,
[job.id]
);
} catch (error) {
const newStatus = job.attempts >= job.max_attempts ? 'failed' : 'pending';
await db.query(
`UPDATE jobs SET status = $1, error = $2, locked_at = NULL, locked_by = NULL WHERE id = $3`,
[newStatus, error.message, job.id]
);
}
}
const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
This DIY approach is fine for low-volume use cases. The pg-boss library is basically a much more robust version of this same pattern, with battle-tested handling for edge cases you haven't thought of yet.
Database Queue vs. Redis Queue: When to Use Which
This isn't about which one is "better" — it's about which one is right for your situation right now.
Use a Database Queue When
- You already have PostgreSQL and don't want to operate another service. One database to manage is better than two services.
- Your job volume is moderate — hundreds or low thousands of jobs per minute. Database queues handle this easily.
- You need transactional job enqueuing — creating a user and enqueuing the welcome email in the same database transaction means either both succeed or neither does. You can't do this with Redis.
- You're on a constrained budget — managed PostgreSQL is cheaper than managed PostgreSQL plus managed Redis.
- You're deploying to platforms that don't offer Redis easily (some PaaS environments, edge runtimes).
- Job durability matters more than speed — PostgreSQL writes are durable by default; Redis persistence is optional and configurable.
Use Redis (BullMQ) When
- You need high throughput — tens of thousands of jobs per minute or more. Redis is in-memory and orders of magnitude faster for queue operations.
- You need real-time job processing — Redis pub/sub means workers are notified instantly when a job arrives. Database queues poll on an interval, so there's always a small delay.
- You need advanced queue features — rate limiting, flow control, delayed jobs with millisecond precision, job progress tracking, and priority queues are all first-class features in BullMQ.
- You're already using Redis for caching or sessions. Adding BullMQ to an existing Redis instance costs nothing extra.
- You're building something that scales to large job volumes and want the most commonly supported ecosystem.
Rule of thumb: Start with a database queue. If you hit performance limits or need features it can't provide, migrate to Redis. Most apps that think they need Redis-level throughput don't actually process enough jobs to feel the difference.
Pros and Cons
Pros of Database-Backed Queues
- No extra infrastructure — one database to operate, back up, and monitor instead of two services
- Transactional enqueuing — enqueue a job inside a database transaction; if the transaction rolls back, the job is never enqueued
- Familiar tooling — query your job table directly with SQL; use your existing database GUI to inspect jobs
- ACID guarantees — jobs are durable the moment they're committed; no risk of data loss if the process crashes
- Simpler deployment — no Redis to configure, no connection string to manage, no Redis AUTH tokens to rotate
- Easy debugging —
SELECT * FROM pgboss.job WHERE name = 'send-welcome-email' AND state = 'failed'is all it takes to see what went wrong
Cons of Database-Backed Queues
- Polling overhead — workers check the database on an interval even when there are no jobs; this adds read load to your database
- Lower throughput — PostgreSQL isn't optimized for high-frequency queue operations; at tens of thousands of jobs per minute, you'll feel the difference
- Notification latency — there's always a delay between job creation and processing equal to your poll interval (typically 1–10 seconds)
- Table bloat — completed jobs accumulate as rows unless you regularly clean them up; pg-boss handles this automatically, DIY solutions need maintenance
- Adds load to your primary database — high job throughput means more queries hitting the same Postgres instance your app depends on
A Note on Quirrel
Quirrel is another database-backed job queue worth knowing about — it's designed specifically for serverless environments where you can't run a long-lived polling worker. Instead of a worker process that polls forever, Quirrel uses HTTP callbacks: when a job is ready to run, Quirrel makes an HTTP request to a URL in your app.
// With Quirrel (serverless-friendly)
import { Queue } from 'quirrel/next'; // or /express, /fastify, etc.
// Define a queue as a Next.js API route
export default Queue('api/queues/email', async (job) => {
await sendWelcomeEmail(job);
});
// Enqueue from anywhere
import EmailQueue from './queues/email';
await EmailQueue.enqueue(
{ userId: user.id, email: user.email },
{ delay: '5min' } // Send 5 minutes after signup
);
Quirrel is a good fit if you're on Vercel, Netlify, or another serverless platform where you can't run a persistent Node.js worker process. For traditional servers and containers, pg-boss is generally the better choice.
What AI Gets Wrong About Database Queues
1. Reaching for Redis Without Asking
The default AI response to "add background jobs" is Redis + BullMQ. This is correct in many cases but not all. AI rarely asks whether you already have a database that could serve as a queue or whether the job volume justifies the extra service. If you don't specify, you'll get Redis. If you want to evaluate database queues, you have to ask explicitly — like the prompt above.
2. Missing the SKIP LOCKED Clause
When AI writes a DIY polling queue, it frequently forgets SKIP LOCKED. You'll see something like this:
-- ❌ RACE CONDITION — two workers will claim the same job
SELECT id, payload FROM jobs
WHERE status = 'pending'
LIMIT 1;
UPDATE jobs SET status = 'processing' WHERE id = $1;
This looks fine and works fine with a single worker. The moment you add a second worker for scale or redundancy, you get duplicate processing. Always make sure the query uses FOR UPDATE SKIP LOCKED inside a single atomic operation. Ask AI to verify this pattern explicitly: "Does this query handle concurrent workers safely? Show me the SKIP LOCKED SQL."
3. No Cleanup for Completed Jobs
AI generates the happy path but often forgets that your jobs table will grow forever. Completed and failed jobs accumulate as dead rows, bloating the table and slowing queries. pg-boss handles this automatically. DIY queues need a cleanup job:
// Run this periodically — e.g., a scheduled job or a cron
async function cleanupOldJobs() {
await db.query(`
DELETE FROM jobs
WHERE status IN ('done', 'failed')
AND created_at < NOW() - INTERVAL '7 days'
`);
}
A good rule: keep failed jobs for 7 days (so you can debug them), keep successful jobs for 1 day (for audit purposes), and delete everything older than that.
4. Polling Without Backoff
A naive polling loop runs at the same interval regardless of job volume:
// ❌ Hammers the database even when there's nothing to do
while (true) {
const job = await claimNextJob();
if (job) await processJob(job);
await sleep(1000); // Always polls every second
}
Under low load this wastes database resources. The better pattern uses exponential backoff when there are no jobs:
// ✅ Backs off when idle, responds quickly when busy
let pollInterval = 1000; // Start at 1 second
const MAX_INTERVAL = 30000; // Cap at 30 seconds
while (true) {
const job = await claimNextJob();
if (job) {
await processJob(job);
pollInterval = 1000; // Reset to fast polling when jobs exist
} else {
await sleep(pollInterval);
pollInterval = Math.min(pollInterval * 2, MAX_INTERVAL); // Back off
}
}
How to Debug Database Queues with AI Tools
In Cursor
When jobs aren't being processed, highlight your worker code and ask: "This polling worker isn't picking up jobs — check whether the SKIP LOCKED query is correct, whether the worker is actually connecting to the right queue name, and whether the job status transitions are correct." Cursor can trace the entire flow from enqueue to process and spot where jobs are getting stuck.
For pg-boss specifically: "My pg-boss worker is connected but jobs in the 'send-welcome-email' queue stay in state 'created' — what's wrong with this setup?" Knowing the pg-boss state machine (created → active → completed/failed) helps Cursor diagnose the issue.
In Windsurf
Use Cascade for inspecting your job flow across multiple files: "Trace the job lifecycle from when boss.send() is called in auth.js to when the handler completes in emailWorker.js — check for any places the job could silently fail or never be claimed." Windsurf's cross-file analysis is excellent for following data through a distributed system like a job queue.
In Claude Code
Paste your schema and worker code together and ask: "Review this DIY database queue for race conditions — specifically, is the SKIP LOCKED pattern implemented correctly, and are there any scenarios where two workers could process the same job?" Claude Code is particularly good at spotting concurrency bugs that look fine in single-threaded review but fail under load.
Direct Database Debugging
The best thing about database queues is that you can inspect them directly with SQL. These queries cover 90% of debugging scenarios:
-- See all pending jobs
SELECT id, name, data, createdon, startedon
FROM pgboss.job
WHERE state = 'created'
ORDER BY createdon ASC;
-- See failed jobs with error details
SELECT id, name, data, output, createdon, completedon
FROM pgboss.job
WHERE state = 'failed'
ORDER BY completedon DESC
LIMIT 20;
-- Count jobs by state
SELECT state, COUNT(*) FROM pgboss.job GROUP BY state;
-- Check if any jobs are stuck in 'active' (might mean a worker crashed)
SELECT id, name, startedon, NOW() - startedon AS running_for
FROM pgboss.job
WHERE state = 'active'
ORDER BY running_for DESC;
Stuck jobs in 'active' state usually mean a worker crashed mid-job without marking it complete. pg-boss has a built-in expiration mechanism to reclaim these — check your expireInHours configuration if jobs are stuck.
Bonus: Transactional Job Enqueuing
This is the superpower that Redis queues can't match. Because your jobs live in the same database as your application data, you can enqueue a job inside the same database transaction as the data change it depends on:
// Enqueue the job only if the user was successfully created
// If the INSERT fails (duplicate email, constraint violation),
// the job is never enqueued — no orphan jobs, no email to a user who
// doesn't exist in your database
await db.transaction(async (trx) => {
// Create the user
const user = await trx('users').insert({
email: req.body.email,
name: req.body.name,
password_hash: await bcrypt.hash(req.body.password, 12)
}).returning('*');
// Enqueue the job — same transaction
await boss.sendWithConnection(trx.client, 'send-welcome-email', {
userId: user[0].id,
email: user[0].email,
name: user[0].name
});
// If anything above fails and the transaction rolls back,
// the job row is also rolled back. No email sent to a ghost user.
});
pg-boss exposes sendWithConnection() specifically for this pattern. With Redis, you'd have to handle this coordination manually — create the user, check if it succeeded, then enqueue the job — with a window where the user could be created but the job never enqueued.
What to Learn Next
Frequently Asked Questions
A database-backed queue uses your existing relational database (like PostgreSQL or MySQL) as a job queue instead of a dedicated messaging system like Redis. Jobs are stored as rows in a table, workers poll for new rows, and locking mechanisms (like SKIP LOCKED) prevent two workers from grabbing the same job. It's slower than Redis but requires no extra infrastructure.
Use pg-boss unless you have a specific reason to build your own. pg-boss handles the tricky parts — SKIP LOCKED, retries, job expiration, scheduled jobs, and concurrency — so you don't have to. Building your own polling queue is fine for learning, but pg-boss is production-tested and actively maintained.
Use a database queue when: you already have PostgreSQL and don't want to operate another service, your job volume is low to moderate (under a few thousand jobs per minute), you need strong transactional guarantees (enqueue a job only if the database write succeeds), or you're on a constrained budget or a managed platform without easy Redis access.
SKIP LOCKED is a PostgreSQL clause that tells a SELECT query to skip any rows that are currently locked by another transaction. In a job queue, this means each worker grabs the next available job and automatically skips any job that another worker is already processing — without blocking or waiting. It's the key mechanism that makes safe parallel job processing possible with PostgreSQL.
The main downsides are polling overhead (workers have to check the database repeatedly even when there are no jobs), lower throughput than Redis (PostgreSQL isn't optimized for high-frequency queue operations), and table bloat (processed jobs accumulate unless you clean them up). For apps processing millions of jobs per hour, Redis-based queues are a better fit.