DEV Community

Cover image for Postgres Job Queues in 2026: SKIP LOCKED vs pg-boss vs BullMQ (Redis and Postgres), Benchmarked
Waseem Anjum
Waseem Anjum

Posted on Originally published at fabwaseem.com

Postgres Job Queues in 2026: SKIP LOCKED vs pg-boss vs BullMQ (Redis and Postgres), Benchmarked

Cross-posted from my blog. The original, with any updates, lives on fabwaseem.com.

A Postgres job queue used to mean one of two things in Node.js: a hand-written FOR UPDATE SKIP LOCKED query or pg-boss. Since BullMQ 6 shipped in July 2026, there is a third option: BullMQ now runs on PostgreSQL as well as Redis, with the same Queue and Worker API. So I put all four on one box and measured them: enqueue speed, processing throughput, how fast an idle worker picks up a new job, what happens when a worker is killed mid-job, and how much space a waiting job takes.

Three results surprised me. pg-boss with its default worker settings processed 5 jobs per second with 10 workers, because each worker fetches one job and then waits out a 2-second polling interval. One option fixed that, and pg-boss then matched BullMQ on Redis at about 29,000 jobs/s. The hand-written SKIP LOCKED worker got a killed worker's job back in 0.01 seconds, while every library took 60 seconds with its defaults. And once each job did 10 ms of simulated work, all four landed within about 12% of each other.

TL;DR: SKIP LOCKED vs pg-boss vs BullMQ

If your data already lives in PostgreSQL and you process up to a few thousand jobs per second, you don't need Redis for a job queue. Use pg-boss, but set batchSize and turn on LISTEN/NOTIFY, or its defaults will cap you at a few jobs per second. Write your own SKIP LOCKED queue only for simple, short jobs. Pick BullMQ on Redis when you need the highest throughput or BullMQ's features like flows, rate limiting and its dashboards, and try BullMQ's Postgres backend if you want that API without running Redis.

50,000 no-op jobs, 10 workers (median of 3) Jobs/s Idle pickup p50 Killed job back after Bytes per waiting job
SKIP LOCKED, my 60 lines, 1 job per fetch 12,588 1.71 ms (LISTEN/NOTIFY) 0.01 s 213
SKIP LOCKED, 50 jobs per fetch 148,341 not tested not tested 213
pg-boss 12.38.0, defaults 5 9,665 ms (fell behind) 15+ min (default expiry, not measured) 297
pg-boss 12.38.0, batchSize: 50 + notify 29,323 3.19 ms 60 s (expireInSeconds 30) 297
BullMQ 6.5.0 on Redis 8.0.2 27,461 1.25 ms 60 s 618 (RAM)
BullMQ 6.5.0 on PostgreSQL 18.6 7,045 2.69 ms 60 s 576

"Idle pickup" is the time from add() to the handler starting while workers wait for work. "Killed job back after" is how long a job stayed stuck after I SIGKILLed the worker holding it. The pg-boss throughput row with batchSize: 50 ran without notify, because notify only changes how fast workers wake up for new jobs.

How I tested

Everything ran on one Linux VM: 8 vCPUs (Intel Xeon, KVM), 15 GB of RAM, Debian 13. Database, Redis and Node.js shared the machine and talked over loopback TCP, so there's no network latency in these numbers. On a real network every round trip costs more, and the per-job gaps grow.

  • PostgreSQL 18.6 from the PGDG apt repo, with shared_buffers = 1GB and max_connections = 300. Everything else stayed at defaults, including fsync = on and synchronous_commit = on, so every commit waited for the WAL flush.
  • Redis 8.0.2, the Debian 13 package, with Debian's default config: RDB snapshots, no AOF. That means Redis acknowledged writes before they reached disk while Postgres flushed every commit. That's how most people run them, but it's a big part of the throughput gap, so keep it in mind.
  • Node.js 22.20.0 with the latest npm releases on October 28, 2026: pg 8.23.1, pg-boss 12.38.0, bullmq 6.5.0 and ioredis 6.0.0.
  • Each test ran in a fresh Node process against an empty queue: I dropped the table or schema (or ran FLUSHALL on Redis) first. Payload: { userId, email, template }, about 70 bytes of JSON. I report the median of 3 runs unless I say otherwise.
  • "Workers" means jobs in flight at once: BullMQ's concurrency, pg-boss's localConcurrency and the number of polling loops (each with its own connection) in my SKIP LOCKED worker.

The scripts and raw JSON results are in my working folder. Every number below comes from them.

The contenders

SKIP LOCKED, hand-written. One table, one index, and a worker that claims rows with FOR UPDATE SKIP LOCKED inside a transaction. When the handler finishes, the transaction commits and the row is gone. If the handler throws, the transaction rolls back and the job is available again. A statement-level trigger sends NOTIFY on insert so idle workers wake up at once.

pg-boss 12.38.0. The established Postgres queue for Node.js: retries with backoff, cron schedules, dead-letter queues, priorities, singleton and debounce policies, group concurrency and flows. It also uses SKIP LOCKED under the hood.

BullMQ 6.5.0 on Redis. The most popular Node.js queue, built on Redis Lua scripts. This is the setup from my AI automation pipeline with BullMQ post.

BullMQ 6.5.0 on PostgreSQL. New in v6. You install pg and pass createPostgresBackend as the last constructor argument. BullMQ creates its own bullmq schema with tables and SQL functions, claims jobs with FOR UPDATE SKIP LOCKED and waits with LISTEN/NOTIFY, according to its source. The BullMQ docs still call Redis "the default and the most battle-tested option".

Enqueue speed

How fast each one accepts new jobs: 5,000 add() calls one after another, 20,000 with 50 in flight, and 20,000 in bulk batches of 500.

Enqueue (jobs/s, median of 3) One at a time p50 per add 50 in flight Bulk, 500 per call
SKIP LOCKED (INSERT) 5,651 0.117 ms 14,611 178,287
pg-boss send() / insert() 1,301 0.716 ms 10,463 49,164
BullMQ on Redis add() / addBulk() 15,359 0.056 ms 67,909 40,202
BullMQ on Postgres add() / addBulk() 4,263 0.172 ms 12,358 33,151

Redis wins single adds by a wide margin, which is what you'd expect from an in-memory store that doesn't wait for disk. In bulk, Postgres catches up: a multi-row INSERT of 500 jobs commits once, so the flush cost is shared. My plain INSERT ... SELECT FROM jsonb_array_elements(...) was the fastest bulk path of all. pg-boss's single send() was the slowest at 0.72 ms per call (I didn't profile why), but its bulk insert() beat both BullMQ backends.

Processing throughput

I loaded 50,000 jobs, started the workers and timed how long it took to drain the queue. The handler did nothing, so this measures pure queue overhead. Runs were capped at 30 seconds, and a capped run reports jobs processed divided by 30.

Drain 50,000 no-op jobs (jobs/s, median of 3) 1 worker 10 workers 50 workers
SKIP LOCKED, 1 job per fetch 2,791 12,588 12,433
SKIP LOCKED, 50 jobs per fetch 63,972 148,341 88,266
pg-boss, defaults (batchSize: 1) 0.5 (capped) 5 (capped) 25 (capped)
pg-boss, batchSize: 50, burstWhenBatchFull 5,763 29,323 28,389
BullMQ on Redis 10,556 27,461 29,969
BullMQ on Postgres 982 (capped) 7,045 9,185

A few things stand out:

  • Batching is the biggest lever on Postgres. Claiming 50 rows per query cut the per-job cost so much that my 60-line worker drained 50,000 jobs in a third of a second. The catch with my version: all 50 jobs share one transaction, so one failing job rolls back the whole batch.
  • More workers stopped helping at 10. With Postgres, Node.js and 50 connections fighting over 8 vCPUs, 50 workers were no faster than 10, and for batched SKIP LOCKED they were slower.
  • BullMQ on Postgres was about 4x slower than on Redis at 10 workers (7,045 vs 27,461 jobs/s). The BullMQ docs say to expect "~1.5–2× fewer jobs/s" for processing. On my box the gap was bigger, and with one worker it was about 11x. Each job state change is a durable Postgres transaction here, while Redis with default settings never waits for disk.

The pg-boss default that ran 5 jobs per second

This is the result I'd most like every pg-boss user to see. With work() and default options, 10 workers processed 150 jobs in 30 seconds.

The reason is in pg-boss's worker loop. Each worker fetches batchSize jobs (default 1), runs the handler, and then waits for pollingIntervalSeconds (default 2) before fetching again. It only skips the wait when a NOTIFY arrives or when the fetch returned a full batch with burstWhenBatchFull on, and that option is documented as ignored when batchSize is 1. So localConcurrency: 10 with defaults means 10 jobs every 2 seconds, however long the backlog is.

The fix is one object:

```ts title="pg-boss worker that keeps up"
import { PgBoss } from "pg-boss";

const boss = new PgBoss({
connectionString: process.env.DATABASE_URL,
useListenNotify: true, // one dedicated LISTEN connection
});
await boss.start();
await boss.createQueue("emails", { notify: true }); // NOTIFY on every new job

await boss.work(
"emails",
{
localConcurrency: 10,
batchSize: 50, // fetch up to 50 jobs per query
burstWhenBatchFull: true, // keep fetching while batches come back full
},
async (jobs) => {
for (const job of jobs) await sendEmail(job.data);
},
);




With `batchSize: 50` and `burstWhenBatchFull: true`, the same 10 workers drained 50,000 jobs at 29,323 jobs/s, a bit faster than BullMQ on Redis in my runs. The handler always receives an array of jobs, now up to 50 at a time. If it throws, the whole batch fails and retries, so either keep handlers idempotent or set `perJobResults: true` and return a result for each job.

## Pickup latency: polling vs LISTEN/NOTIFY

Throughput is about backlogs. Most apps spend their time with an empty queue, where the question is how long a new job waits before a worker starts it. I started 10 idle workers, added 300 jobs at 20 per second and measured from just before `add()` to the moment the handler started.

| Idle pickup, 300 jobs at 20/s (median of 3) | p50 | p95 | p99 |
| --- | --- | --- | --- |
| SKIP LOCKED + `NOTIFY` trigger | 1.71 ms | 2.54 ms | 19.01 ms |
| pg-boss, defaults (polling every 2 s, `batchSize: 1`) | 9,665 ms | 18,446 ms | 18,704 ms |
| pg-boss, `batchSize: 50`, polling every 2 s | 972 ms | 1,905 ms | 1,972 ms |
| pg-boss, `useListenNotify` + queue `notify: true` | 3.34 ms | 6.23 ms | 14.90 ms |
| pg-boss, notify + `batchSize: 50` | 3.19 ms | 4.38 ms | 20.57 ms |
| BullMQ on Redis | 1.25 ms | 2.11 ms | 2.80 ms |
| BullMQ on Postgres | 2.69 ms | 3.54 ms | 18.70 ms |

With defaults, pg-boss couldn't even keep up with 20 jobs per second: only 130 of the 300 jobs had started 10 seconds after the last one was added, and the backlog kept growing. With `batchSize: 50` it kept up, but each job still waited for the next poll, about 1 second on average and up to 2. Turning on LISTEN/NOTIFY brought pickup down to 3 ms, close to BullMQ.

LISTEN/NOTIFY holds one dedicated connection per process, and pg-boss's docs warn it won't work through PgBouncer in transaction pooling mode. Polling keeps running as a fallback, so a broken listener costs latency, not jobs.

## Crash recovery: what happens to a job when a worker dies

I added one job, let a worker in a child process start it, killed that process with `SIGKILL` 200 ms later, started a fresh worker and timed how long the job took to come back.

| Worker killed mid-job | Job available again after |
| --- | --- |
| SKIP LOCKED (job claimed inside a transaction) | 0.01 s |
| BullMQ on Redis, defaults | 60.0 s |
| BullMQ on Postgres, defaults | 60.0 s |
| pg-boss, `expireInSeconds: 30` | 60.0 s |
| pg-boss, `heartbeatSeconds: 10` | 60.0 s (1 run) |

The SKIP LOCKED worker recovers instantly because the claim is a row lock inside an open transaction. When the process dies, its TCP connection closes, Postgres rolls the transaction back and the next worker can lock the row. Nothing has to time out.

The libraries mark the job as active and rely on a timer to notice it was abandoned. BullMQ's defaults are a 30-second `lockDuration` and a 30-second `stalledInterval`, and the job came back after 60 seconds on both backends. pg-boss expires abandoned jobs in its maintenance pass, which runs every 60 seconds by default (`monitorIntervalSeconds` and `superviseIntervalSeconds`), so `expireInSeconds: 30` and `heartbeatSeconds: 10` both came back at 60 seconds. With pg-boss's default `expireInSeconds` of 900, a crashed job would stay stuck for at least 15 minutes. I didn't wait that one out.

The instant recovery has a limit. If a worker's machine disappears instead of its process (a network partition or a VM that freezes), Postgres doesn't see the socket close until TCP keepalives give up. Set `tcp_keepalives_idle` and friends on the server, and `idle_in_transaction_session_timeout` as a backstop.

## Enqueue a job in the same transaction as your data

This is the feature Redis can't give you. A classic bug: you insert an order, commit, then crash before you enqueue the confirmation email. Or you enqueue first, the order insert fails, and the customer gets an email for an order that doesn't exist. If the job lives in the same database, it can commit or roll back with the order.

pg-boss supports this with the `db` option, which takes any object with an `executeSql` method:



```ts title="Enqueue inside your own transaction (pg-boss)"
const client = await pool.connect();
try {
  await client.query("BEGIN");
  const { rows } = await client.query(
    "INSERT INTO orders (total) VALUES ($1) RETURNING id", [total]);
  await boss.send("emails", { orderId: rows[0].id }, {
    db: { executeSql: (text, values) => client.query(text, values) },
  });
  await client.query("COMMIT");
} catch (err) {
  await client.query("ROLLBACK"); // the job disappears with the order
  throw err;
} finally {
  client.release();
}
Enter fullscreen mode Exit fullscreen mode

I tested it: after ROLLBACK, both the order and the pg-boss job were gone. After COMMIT, both were there. My SKIP LOCKED table behaved the same way, since it's just an INSERT.

BullMQ's Postgres backend can't do this today. Its backend interface "intentionally exposes no connection or transaction type", in the words of its source, so queue.add() always runs on BullMQ's own pool. With either BullMQ backend you need an outbox table or an enqueue after commit.

Storage per waiting job

I enqueued 100,000 jobs in bulk and measured the growth of the queue's tables and indexes (pg_total_relation_size) or Redis used_memory. One run each.

100,000 waiting jobs Total Per job
SKIP LOCKED table + index 21.3 MB 213 bytes
pg-boss 29.7 MB 297 bytes
BullMQ on Postgres 57.6 MB 576 bytes
BullMQ on Redis 61.8 MB (RAM) 618 bytes

None of these matter for a normal backlog. They matter when a backlog grows to millions of jobs: on Redis it's RAM, and on Postgres it's disk. Both libraries also keep finished jobs by default: pg-boss deletes completed jobs after 7 days (deleteAfterSeconds), and BullMQ keeps completed jobs until you set removeOnComplete, so set a retention you can live with.

With real work, the queue barely matters

No-op jobs exaggerate the differences. I reran the drain with a handler that waits 10 ms (like a fast API call) and 50 jobs in flight, which caps out at 5,000 jobs/s in theory.

20,000 jobs, 10 ms each, 50 in flight (median of 3) Jobs/s Share of the 5,000/s ceiling
SKIP LOCKED, 1 job per fetch 4,189 84%
pg-boss, batchSize: 50 4,587 92%
BullMQ on Redis 4,469 89%
BullMQ on Postgres 4,037 81%

Once a job does real work, every option except pg-boss's defaults spends most of its time in your handler. For the AI jobs I usually queue, where one model call takes seconds, the queue's overhead is noise. Pick on features, operations and failure behavior, not on no-op benchmarks.

A SKIP LOCKED queue in about 60 lines

If your jobs are short and you only need "run this once, retry on failure", this is all it takes. It's the worker I benchmarked, trimmed for reading:

```sql title="schema.sql"
CREATE TABLE jobs (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
queue text NOT NULL,
payload jsonb NOT NULL,
run_at timestamptz NOT NULL DEFAULT now(),
attempts int NOT NULL DEFAULT 0
);
CREATE INDEX jobs_fetch_idx ON jobs (queue, run_at, id);

-- Wake idle workers on insert (one NOTIFY per statement, not per row)
CREATE FUNCTION jobs_notify() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN PERFORM pg_notify('jobs', ''); RETURN NULL; END $$;
CREATE TRIGGER jobs_notify AFTER INSERT ON jobs
FOR EACH STATEMENT EXECUTE FUNCTION jobs_notify();






```ts title="queue.ts"
import pg from "pg";

const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL, max: 14 });

export async function enqueue(queue: string, payload: unknown, db: pg.Pool | pg.PoolClient = pool) {
  await db.query("INSERT INTO jobs (queue, payload) VALUES ($1, $2)", [queue, payload]);
}

export async function startWorkers(
  queue: string,
  concurrency: number,
  handler: (payload: any) => Promise<void>,
) {
  const waiters = new Set<() => void>();
  const listener = new pg.Client({ connectionString: process.env.DATABASE_URL });
  await listener.connect();
  await listener.query("LISTEN jobs");
  listener.on("notification", () => { waiters.forEach((w) => w()); waiters.clear(); });

  const loop = async () => {
    const client = await pool.connect();
    for (;;) {
      await client.query("BEGIN");
      const { rows } = await client.query(
        `DELETE FROM jobs WHERE id IN (
           SELECT id FROM jobs
           WHERE queue = $1 AND run_at <= now()
           ORDER BY run_at, id
           FOR UPDATE SKIP LOCKED
           LIMIT 1)
         RETURNING id, payload`, [queue]);
      if (rows.length === 0) {
        await client.query("COMMIT");
        // sleep until NOTIFY, or 1 s as a fallback poll
        await new Promise<void>((r) => { waiters.add(r); setTimeout(r, 1000); });
        continue;
      }
      try {
        await handler(rows[0].payload);
        await client.query("COMMIT");   // job done, row deleted
      } catch {
        await client.query("ROLLBACK"); // row is back in the queue
      }
    }
  };
  for (let i = 0; i < concurrency; i++) loop();
}
Enter fullscreen mode Exit fullscreen mode

Because enqueue accepts a client, you get transactional enqueue for free: pass the client of your open transaction. I ran these two files as written: 200 jobs with 4 workers, 20 of which threw on their first try, were all processed exactly once with 0 rows left over, and a job enqueued inside a rolled-back transaction never ran.

What it doesn't do is everything pg-boss and BullMQ spent years on: retry limits and backoff (a rollback doesn't save the attempts increment), delayed retries, cron, dead-letter queues, priorities, rate limits, deduplication, metrics and a UI. It also holds a connection and an open transaction for the whole job. That's fine for jobs that take milliseconds. For jobs that take minutes it ties up your pool, and long-open transactions hold back vacuum. For long jobs, claim with a lease and commit right away:

```sql title="Lease pattern for long jobs"
-- add once: ALTER TABLE jobs ADD COLUMN locked_until timestamptz;
UPDATE jobs SET locked_until = now() + interval '5 minutes', attempts = attempts + 1
WHERE id = (
SELECT id FROM jobs
WHERE queue = $1 AND run_at <= now()
AND (locked_until IS NULL OR locked_until < now())
ORDER BY run_at, id
FOR UPDATE SKIP LOCKED
LIMIT 1)
RETURNING id, payload, attempts;
-- on success: DELETE FROM jobs WHERE id = $1




The trade-off is the same one the libraries make: a crashed job now waits for its lease to expire instead of coming back in 10 ms.

## BullMQ on PostgreSQL: setup

Switching an existing BullMQ app to Postgres is mostly one argument:



```ts title="BullMQ 6 with the PostgreSQL backend"
import { Queue, Worker, createPostgresBackend } from "bullmq";

const opts = {
  connection: { connectionString: process.env.DATABASE_URL, max: 20, migrate: true },
};

const queue = new Queue("emails", opts, createPostgresBackend);
await queue.add("welcome", { userId: 42 });

new Worker("emails", async (job) => sendEmail(job.data), { ...opts, concurrency: 10 }, createPostgresBackend);
Enter fullscreen mode Exit fullscreen mode

migrate: true creates or upgrades the bullmq schema on first connect. You can also call runMigrations() from a deploy step instead. BullMQ requires PostgreSQL 13 or newer and recommends 14 or newer, per its PostgreSQL backend guide. Size the pool for your worker concurrency, because each worker's queries come out of it and the LISTEN connection is extra.

It worked without surprises in my tests, and the identical API is a real advantage if you already know BullMQ. But it's three months old, and on my box it was the slowest at processing no-op jobs. I'd use it for new projects that want BullMQ's features on a Postgres-only stack, and keep busy existing queues on Redis for now.

Which Postgres job queue should you choose?

  • You already run Postgres and want a full-featured queue: pg-boss. Retries, cron, dead letters, priorities and transactional enqueue, with no extra infrastructure. Set batchSize and burstWhenBatchFull on busy queues, useListenNotify plus queue notify where latency matters, and a lower expireInSeconds than the 15-minute default for short jobs.
  • You need the most throughput, or BullMQ's ecosystem: BullMQ on Redis. It had the fastest single enqueues, the lowest pickup latency and strong throughput without tuning. You pay with a second datastore and no transactional enqueue.
  • You like BullMQ but don't want Redis: BullMQ on PostgreSQL. Same code, fewer moving parts, lower throughput, and no enqueue inside your own transaction yet.
  • Your jobs are short and simple: a SKIP LOCKED table. Fastest in batch mode, instant crash recovery and no dependency. You own retries, scheduling and monitoring from then on.

The rough throughput limits from my no-op runs (about 7,000 to 150,000 jobs/s depending on the option) are far above what most apps need. If your queue does fewer than a few hundred jobs per second, choose on features and operations.

If you're also deciding where search or IDs live, I ran similar tests for Postgres full-text search vs Elasticsearch and UUIDv7 primary keys in Postgres. For the ORM layer on top, see Drizzle vs Prisma, and for a full worker with retries, backoff and a dead-letter queue, my BullMQ AI automation pipeline.

Limitations of this benchmark

  • One VM, everything on loopback. Real networks add a round trip to every query and widen the gaps between per-job and batched approaches.
  • Redis ran without AOF, Postgres with full durability. With appendfsync always, Redis would pay a disk flush per write too. I didn't test that, or synchronous_commit = off on Postgres.
  • No-op handlers measure queue overhead only. The 10 ms test is closer to real life.
  • Crash recovery ran 3 times per option except heartbeatSeconds: 10 (once). Storage ran once per option.
  • I didn't test retries, delayed jobs, priorities, cron or flows, which is where the libraries earn their keep.

Top comments (0)