PG backend: maximumBlockTimeout is a Redis-derived constant on a shared path, so an idle worker polls every 10s
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):
// 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:
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:
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:
// 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
Source: taskforcesh/bullmq