Backend & APIs

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

I benchmarked a hand-written SKIP LOCKED queue, pg-boss 12 and BullMQ 6 on both Redis and PostgreSQL: enqueue speed, throughput, pickup latency, crash recovery, transactional enqueue and storage per job.

Postgres job queue benchmark cover: a FOR UPDATE SKIP LOCKED query with pg-boss and BullMQ throughput results
On this page
  1. TL;DR: SKIP LOCKED vs pg-boss vs BullMQ
  2. How I tested
  3. The contenders
  4. Enqueue speed
  5. Processing throughput
  6. The pg-boss default that ran 5 jobs per second
  7. Pickup latency: polling vs LISTEN/NOTIFY
  8. Crash recovery: what happens to a job when a worker dies
  9. Enqueue a job in the same transaction as your data
  10. Storage per waiting job
  11. With real work, the queue barely matters
  12. A SKIP LOCKED queue in about 60 lines
  13. BullMQ on PostgreSQL: setup
  14. Which Postgres job queue should you choose?
  15. Limitations of this benchmark

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/sIdle pickup p50Killed job back afterBytes per waiting job
SKIP LOCKED, my 60 lines, 1 job per fetch12,5881.71 ms (LISTEN/NOTIFY)0.01 s213
SKIP LOCKED, 50 jobs per fetch148,341not testednot tested213
pg-boss 12.38.0, defaults59,665 ms (fell behind)15+ min (default expiry, not measured)297
pg-boss 12.38.0, batchSize: 50 + notify29,3233.19 ms60 s (expireInSeconds 30)297
BullMQ 6.5.0 on Redis 8.0.227,4611.25 ms60 s618 (RAM)
BullMQ 6.5.0 on PostgreSQL 18.67,0452.69 ms60 s576

"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 timep50 per add50 in flightBulk, 500 per call
SKIP LOCKED (INSERT)5,6510.117 ms14,611178,287
pg-boss send() / insert()1,3010.716 ms10,46349,164
BullMQ on Redis add() / addBulk()15,3590.056 ms67,90940,202
BullMQ on Postgres add() / addBulk()4,2630.172 ms12,35833,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 worker10 workers50 workers
SKIP LOCKED, 1 job per fetch2,79112,58812,433
SKIP LOCKED, 50 jobs per fetch63,972148,34188,266
pg-boss, defaults (batchSize: 1)0.5 (capped)5 (capped)25 (capped)
pg-boss, batchSize: 50, burstWhenBatchFull5,76329,32328,389
BullMQ on Redis10,55627,46129,969
BullMQ on Postgres982 (capped)7,0459,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
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)p50p95p99
SKIP LOCKED + NOTIFY trigger1.71 ms2.54 ms19.01 ms
pg-boss, defaults (polling every 2 s, batchSize: 1)9,665 ms18,446 ms18,704 ms
pg-boss, batchSize: 50, polling every 2 s972 ms1,905 ms1,972 ms
pg-boss, useListenNotify + queue notify: true3.34 ms6.23 ms14.90 ms
pg-boss, notify + batchSize: 503.19 ms4.38 ms20.57 ms
BullMQ on Redis1.25 ms2.11 ms2.80 ms
BullMQ on Postgres2.69 ms3.54 ms18.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-jobJob available again after
SKIP LOCKED (job claimed inside a transaction)0.01 s
BullMQ on Redis, defaults60.0 s
BullMQ on Postgres, defaults60.0 s
pg-boss, expireInSeconds: 3060.0 s
pg-boss, heartbeatSeconds: 1060.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
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();
}

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 jobsTotalPer job
SKIP LOCKED table + index21.3 MB213 bytes
pg-boss29.7 MB297 bytes
BullMQ on Postgres57.6 MB576 bytes
BullMQ on Redis61.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/sShare of the 5,000/s ceiling
SKIP LOCKED, 1 job per fetch4,18984%
pg-boss, batchSize: 504,58792%
BullMQ on Redis4,46989%
BullMQ on Postgres4,03781%

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
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
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();
}

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
-- 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
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);

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.

Questions