#4601·bullmq

PG backend: maximumBlockTimeout is a Redis-derived constant on a shared path, so an idle worker polls every 10s

Author: MGrinCreated Aug 21, 2026Updated Aug 29, 2026

Summary

maximumBlockTimeout is a module-level constant in Worker, justified by a Redis limitation, but it is applied on a code path shared by every backend. With the new PostgreSQL backend it forces a poll every 10 seconds whenever any delayed job exists, which prevents an idle worker from ever going quiet.

minimumBlockTimeout is already delegated to the backend. maximumBlockTimeout is not, and I think that asymmetry is the bug.

The code

src/classes/worker.ts (v6.2.0):

typescript
// 10 seconds is the maximum time a BZPOPMIN can block.
const maximumBlockTimeout = 10;

BZPOPMIN is a Redis command, and the reference on line 858 is to #1658, which is about Redis reconnection behaviour.

It is consumed in getBlockTimeout(), which lives in the shared Worker class:

typescript
protected getBlockTimeout(blockUntil: number): number {
  const opts: WorkerOptions = <WorkerOptions>this.opts;
  if (blockUntil) {                       // when there are delayed jobs
    const blockDelay = blockUntil - Date.now();
    if (blockDelay <= 0) {
      return blockDelay;
    } else if (blockDelay < this.minimumBlockTimeout * 1000) {
      return this.minimumBlockTimeout;
    } else {
      return Math.min(blockDelay / 1000, maximumBlockTimeout);   // <-- here
    }
  } else {
    return Math.max(opts.drainDelay, this.minimumBlockTimeout);  // drainDelay only reachable here
  }
}

The result is passed to this.backend.waitForJob(blockTimeout), so the Postgres backend inherits a ceiling derived from a Redis command's semantics.

Meanwhile the minimum is already backend-aware:

typescript
get minimumBlockTimeout(): number {
  return this.backend.minimumBlockTimeout;
}

Why it matters in practice

drainDelay is only reachable in the else branch — when no delayed job exists. An application with repeatable/scheduled jobs almost always has one pending, so drainDelay is effectively unreachable and the 10s ceiling always applies.

We run 11 repeatable schedules, so a completely idle worker issues its "any work?" queries every 10 seconds indefinitely — roughly 17,280 queries a day that find nothing.

On a serverless Postgres that bills compute time and suspends when idle (Neon, with a 300s idle window), this means the database can never suspend. Measured on our own deployment: the database currently sleeps ~7.4h/day, and a Postgres-backed queue would take that to 0, a ~42% increase in the database compute bill for a workload that is genuinely idle.

This is not a complaint about the default value. On Redis the 10s ceiling costs nothing — the connection is already open and BZPOPMIN is cheap. It is specifically that a Redis-shaped constraint is applied to a backend with different economics, where an idle poll has a price.

Suggested fix

Delegate it the same way the minimum already is:

typescript
// IQueueBackend
readonly maximumBlockTimeout: number;

// worker.ts
get maximumBlockTimeout(): number {
  return this.backend.maximumBlockTimeout;
}

Redis backend keeps 10 and nothing changes for existing users. The Postgres backend can choose a larger value, or expose it as an option so applications can trade latency for idle cost.

Happy to open a PR if the approach looks right.

Environment

  • bullmq 6.2.0 (source reviewed at tag v6.2.0)
  • PostgreSQL backend on Neon, suspend_timeout = 300s
  • 11 repeatable job schedules