Scaling BullMQ with Redis Cluster: A Production Guide
A single Redis instance works great for small-to-medium BullMQ deployments. But as your job volume grows — thousands of queues, millions of jobs per day, or strict tenant isolation requirements — you inevitably hit the limits of a single Redis node: memory caps, CPU saturation from blocking commands, and a single point of failure.
When you outgrow standalone Redis, Redis Cluster seems like the natural next step — horizontal sharding, automatic failover, linear scalability. But threading BullMQ through Redis Cluster is not plug-and-play. BullMQ's internal Lua scripts operate on multiple keys atomically, which violates Redis Cluster's slot-based partitioning. The dreaded CROSSSLOT error awaits the unwary.
In this guide, we'll cover everything you need to run BullMQ on Redis Cluster in production:
- How Redis Cluster works with BullMQ (the hash tag pattern)
- Three sharding strategies for queue distribution
- Multi-tenant patterns (dedicated queues, BullMQ Pro Groups, tenant namespacing)
- Production deployment: connection pooling, graceful shutdown, failover recovery
- Complete TypeScript code examples for each pattern
- Common pitfalls and how to avoid them
This post assumes you have a Redis Cluster running and a basic understanding of BullMQ. If you're new to BullMQ, check out the official getting started guide first.
How BullMQ Works with Redis Cluster
The CROSSSLOT Problem
BullMQ uses Lua scripts extensively for atomic job lifecycle operations — moving jobs between wait, active, delayed, and completed lists, managing locks, maintaining priority queues, and emitting events. Each of these operations touches multiple Redis keys in a single script execution:
{myapp}:email:wait
{myapp}:email:active
{myapp}:email:meta
{myapp}:email:lock
{myapp}:email:events
Redis Cluster partitions keys across 16,384 hash slots using CRC16. Keys are assigned to slots independently, so without intervention, BullMQ's related keys hash to different slots on different cluster nodes. When a Lua script tries to operate on keys spanning multiple slots, Redis returns:
CROSSSLOT Keys in request don't hash to the same slot
This is the most common failure when attempting to run BullMQ on an unconfigured Redis Cluster.
Redis Hash Tags: The Solution
Redis provides a mechanism to force multiple keys into the same hash slot: hash tags. When a key contains a substring enclosed in curly braces {...}, only that substring is used for slot calculation. All keys sharing the same hash tag land on the same slot and thus the same cluster node.
BullMQ exposes this through the prefix option, which is prepended to every key the library creates:
import { Queue } from "bullmq";
const queue = new Queue("emails", {
prefix: "{myapp}",
connection: clusterConnection,
});
This transforms all queue keys to {myapp}:emails:wait, {myapp}:emails:active, etc. Because {myapp} is identical in every key, they all hash to the same slot — no CROSSSLOT errors.
Critical rule: The prefix must be applied consistently to all BullMQ classes — Queue, Worker, QueueEvents, and QueueScheduler — or you'll get CROSSSLOT errors at runtime.
Prefix Placement Approaches
Approach A: Hash-tag prefix (recommended)
const queue = new Queue("emails", {
prefix: "{myapp}",
connection: cluster,
});
const worker = new Worker("emails", processor, {
prefix: "{myapp}",
connection: cluster,
});
✅ All queue keys in one slot — no CROSSSLOT errors
⚠️ Single slot = no sharding benefit for this one queue
Approach B: Hash-tag queue name (simpler but less readable)
const queue = new Queue("{emails}", { connection: cluster });
This works but makes queue names less readable and is not recommended for systems with multiple queues.
Multiple Queues on One Cluster
If every queue uses the same hash-tag prefix ({myapp}), all queues map to the same slot — which defeats the purpose of sharding across a cluster. The best practice is to use different hash tags per queue (or per group of queues) to distribute load across cluster nodes:
const emailQueue = new Queue("email", {
prefix: "{queue:email}",
connection: cluster,
});
const videoQueue = new Queue("video", {
prefix: "{queue:video}",
connection: cluster,
});
const notificationQueue = new Queue("notification", {
prefix: "{queue:notification}",
connection: cluster,
});
Each queue's keys now land on potentially different cluster nodes, distributing both memory and CPU load. Redis Cluster resizing (adding/removing shards) rebalances slots, but hash tags keep each queue's keys together as a unit.
⚠️ keyPrefix Incompatibility (Critical Warning)
ioredis provides a keyPrefix option that automatically prepends a string to all keys. Do not use this with BullMQ. BullMQ maintainer manast confirmed that keyPrefix causes hard-to-debug errors like "Missing lock for job X."
BullMQ manages its own prefix logic internally — it constructs Redis key names before sending them to ioredis. When keyPrefix is set, the prefix is applied again, producing double-prefixed keys like myprefix{myprefix}:email:wait, which breaks BullMQ's internal key lookups.
The wrong way:
import { Cluster } from "ioredis";
// ❌ Do NOT use keyPrefix with BullMQ
const cluster = new Cluster(nodes, {
redisOptions: {
keyPrefix: "myapp:", // This will break BullMQ
},
});
The right way:
import { Cluster } from "ioredis";
// ✅ No keyPrefix — use BullMQ's prefix option instead
const cluster = new Cluster(nodes, {
redisOptions: {
maxRetriesPerRequest: null,
},
});
const queue = new Queue("email", {
connection: cluster,
prefix: "{myapp}", // BullMQ handles prefixing internally
});
If you need key namespacing for non-BullMQ Redis operations, create a separate ioredis connection with keyPrefix for that purpose and keep your BullMQ connection clean.
Sharding Strategies
Choosing the right sharding strategy depends on your workload profile, tenant model, and operational complexity tolerance. Here are three battle-tested approaches.
Strategy 1: Per-Queue Hash Tags (Simplest Sharding)
Concept: Each BullMQ queue uses a different hash-tag prefix, distributing queues across different cluster slots and nodes.
When to use: Multiple independent queues with different job types and workload profiles.
Code example:
import { Cluster } from "ioredis";
import { Queue, Worker } from "bullmq";
const cluster = new Cluster(
[
{ host: "redis-1", port: 6379 },
{ host: "redis-2", port: 6379 },
{ host: "redis-3", port: 6379 },
],
{
redisOptions: {
maxRetriesPerRequest: null,
enableReadyCheck: false,
},
scaleReads: "master",
}
);
function createQueue(name: string): Queue {
// Each queue gets its own hash tag → different slot → different Redis node
return new Queue(name, {
connection: cluster,
prefix: `{q:${name}}`,
});
}
const emailQueue = createQueue("email");
const videoQueue = createQueue("video");
const notificationQueue = createQueue("notification");
Pros:
- Simple to implement and reason about
- No shared state between queues
- Natural load distribution across cluster nodes
Cons:
- One queue's traffic still hits a single slot/node — no sharding within a queue
- Queue count must grow with load
Strategy 2: Consistent Hashing with Application-Level Routing
Concept: Use a hashing function (e.g., MD5) to distribute queue names across multiple Cluster connections, each pointing to different subsets of cluster nodes.
When to use: Fine-grained control over which Redis node processes which queue; tenant-based routing.
Code example:
import { Cluster } from "ioredis";
import { Queue } from "bullmq";
import { createHash } from "node:crypto";
class ShardedQueueManager {
private connections: Cluster[];
private queues: Map<string, Queue> = new Map();
constructor(clusterNodes: { host: string; port: number }[][]) {
this.connections = clusterNodes.map(
(nodes) =>
new Cluster(nodes, {
redisOptions: { maxRetriesPerRequest: null },
})
);
}
private getConnection(name: string): Cluster {
const hash = createHash("md5").update(name).digest("hex");
const index = parseInt(hash.substring(0, 8), 16) % this.connections.length;
return this.connections[index];
}
getQueue(name: string): Queue {
if (!this.queues.has(name)) {
this.queues.set(
name,
new Queue(name, {
connection: this.getConnection(name),
prefix: `{${name}}`,
})
);
}
return this.queues.get(name)!;
}
}
Pros:
- Predictable placement — same queue always goes to same connection
- Can be combined with tenant-based routing for multi-tenant workloads
- Reduces shared-bottleneck risk from a single Cluster object
Cons:
- More boilerplate to manage
- Resharding requires careful migration of queues between connections
Strategy 3: Shared Cluster Connection (Pooled Access)
Concept: A single Cluster instance shared across all Queues, Workers, and QueueEvents — ioredis handles internal slot routing.
When to use: Most common deployment pattern; simplifies lifecycle management.
Pros:
- Single connection object to manage
- ioredis handles node discovery, slot routing, and failover
- Minimal boilerplate
Cons:
- All traffic goes through one
Clusterobject - Worker duplicate storm at scale (see section below)
This is the most widely used pattern for BullMQ + Redis Cluster. The next sections assume this approach.
Multi-Tenant Patterns
When you need to serve multiple customers (tenants) from a single BullMQ deployment, you have several architectural options ranging from strong isolation to operational simplicity.
Pattern A: One Queue Per Tenant
Concept: Each tenant gets its own BullMQ queue with its own hash-tag prefix, ensuring complete isolation.
When to use: Complete tenant isolation is required; tenants may have different configurations (concurrency, priorities, rate limits).
Structure:
Queue: tenant:acme-corp → prefix: {t:acme-corp}
Queue: tenant:megacorp → prefix: {t:megacorp}
Queue: tenant:startup → prefix: {t:startup}
Code example:
import { Cluster } from "ioredis";
import { Queue, Worker } from "bullmq";
interface TenantConfig {
tenantId: string;
concurrency: number;
rateLimit?: { max: number; duration: number };
}
class MultiTenantQueueManager {
private cluster: Cluster;
private tenantQueues: Map<string, Queue> = new Map();
private tenantWorkers: Map<string, Worker> = new Map();
constructor() {
this.cluster = new Cluster(
[
{ host: process.env.REDIS_CLUSTER_HOST_1!, port: Number(process.env.REDIS_CLUSTER_PORT) },
{ host: process.env.REDIS_CLUSTER_HOST_2!, port: Number(process.env.REDIS_CLUSTER_PORT) },
],
{
redisOptions: {
maxRetriesPerRequest: null,
enableReadyCheck: false,
},
scaleReads: "master",
}
);
}
async addTenant(config: TenantConfig): Promise<void> {
const prefix = `{t:${config.tenantId}}`;
const queue = new Queue(`tenant:${config.tenantId}`, {
connection: this.cluster,
prefix,
});
const worker = new Worker(
`tenant:${config.tenantId}`,
async (job) => {
// Process tenant job
console.log(`[${config.tenantId}] Processing job ${job.id}`);
},
{
connection: this.cluster,
prefix,
concurrency: config.concurrency,
...(config.rateLimit && {
limiter: config.rateLimit,
}),
}
);
this.tenantQueues.set(config.tenantId, queue);
this.tenantWorkers.set(config.tenantId, worker);
}
async removeTenant(tenantId: string): Promise<void> {
await this.tenantWorkers.get(tenantId)?.close();
await this.tenantQueues.get(tenantId)?.close();
this.tenantWorkers.delete(tenantId);
this.tenantQueues.delete(tenantId);
}
getQueue(tenantId: string): Queue | undefined {
return this.tenantQueues.get(tenantId);
}
async closeAll(): Promise<void> {
await Promise.all([
...[...this.tenantWorkers.values()].map((w) => w.close()),
...[...this.tenantQueues.values()].map((q) => q.close()),
]);
await this.cluster.quit();
}
}
Pros:
- Strong isolation — one tenant cannot starve another
- Per-tenant concurrency, rate limits, and priorities
- Easy to monitor per-tenant queue health
Cons:
- Many keys in Redis (N queues × ~10 keys per queue)
- Management overhead for dynamic tenant lifecycle
- Each tenant needs a different hash tag for distribution across nodes
Pattern B: BullMQ Pro Groups (Virtual Queues)
Concept: A single queue with BullMQ Pro's Groups feature — jobs are grouped by group.id and processed round-robin across groups.
When to use: Many tenants/users, each with modest job volume; want fairness without per-tenant queue overhead.
How groups work:
- Jobs with the same
group.idare processed sequentially within the group - Groups are served in round-robin order by the workers
- No hard limit on number of groups — empty groups consume no Redis resources
- BullMQ Pro 7.46.0+ supports group concurrency and group rate limiting simultaneously
Code example:
import { QueuePro, WorkerPro } from "@taskforcesh/bullmq-pro";
const queue = new QueuePro("transcoding", {
connection: clusterConnection,
prefix: "{transcoding}",
});
// Add jobs for different tenants (groups)
await queue.add("transcode", { file: "video1.mp4" }, { group: { id: "tenant-a" } });
await queue.add("transcode", { file: "video2.mp4" }, { group: { id: "tenant-b" } });
await queue.add("transcode", { file: "video3.mp4" }, { group: { id: "tenant-a" } });
// Worker processes jobs round-robin across groups
const worker = new WorkerPro(
"transcoding",
async (job) => {
// Jobs from tenant-a and tenant-b are interleaved fairly
await transcodeFile(job.data.file);
},
{
connection: clusterConnection,
prefix: "{transcoding}",
group: {
limit: { max: 10, duration: 1000 }, // Per-group rate limit (BullMQ Pro)
},
}
);
Pros:
- Single queue → minimal Redis key overhead
- Automatic round-robin fairness across tenants
- Per-group rate limiting
Cons:
- Requires BullMQ Pro license
- Less isolation than per-tenant queues
- Still bound by a single hash tag slot
Pattern C: Tenant ID in Job Data (Simplest)
Concept: One queue for all; tenant ID is just a field in the job data. The worker filters or routes based on tenant.
When to use: Prototyping, small-scale, or tenants that don't need strict isolation.
// Adding jobs with tenant context
await queue.add("process", {
tenantId: "acme-corp",
payload: { /* ... */ },
});
Pros: Simplest — one queue, one worker, one prefix
Cons: No fairness, no isolation, no per-tenant rate limiting
Comparison Matrix
| Pattern | Isolation | Fairness | Per-Tenant Config | Redis Keys | Complexity | License |
|---|---|---|---|---|---|---|
| Per-tenant queue (A) | Strong | Implicit | Yes | High | Medium | OSS |
| Groups (B) | Medium | Built-in | Yes (Pro) | Low | Low | Pro |
| Job data field (C) | None | None | No | Low | Low | OSS |
Production Deployment Considerations
ioredis Cluster Configuration Best Practices
Here's a production-grade ioredis Cluster configuration for BullMQ:
import { Cluster } from "ioredis";
const cluster = new Cluster(
[
{ host: "redis-cluster-1.example.com", port: 6379 },
{ host: "redis-cluster-2.example.com", port: 6379 },
{ host: "redis-cluster-3.example.com", port: 6379 },
],
{
redisOptions: {
maxRetriesPerRequest: null, // REQUIRED for Workers
enableReadyCheck: false, // Avoid false negatives during cluster startup
keepAlive: 30000, // Prevent TCP timeouts on long-lived connections
connectionName: "bullmq-cluster",
commandTimeout: 15000, // Fail fast on truly hung connections
family: 0, // Support both IPv4 and IPv6
},
clusterRetryStrategy: (times) => Math.min(times * 100, 3000),
enableOfflineQueue: true,
scaleReads: "master", // BullMQ writes are critical; read from masters
slotsRefreshTimeout: 30000,
}
);
Parameter deep-dive:
-
maxRetriesPerRequest: null— Workers use blocking commands (BRPOPLPUSH/BZPOPMIN). These must retry indefinitely; setting a finite number will crash the worker when the retry limit is hit. For Queue (producer) instances in HTTP request paths, a finite value like20may be preferable to fail fast. -
enableReadyCheck: false— ioredis's ready check sendsINFOon connect. In Cluster mode, this can fail spuriously during node failover or resharding, causing false connection failure events. -
scaleReads: 'master'— BullMQ relies on strong consistency for job state. Reading from replicas can return stale data — jobs appear "waiting" when they're actually "active" or "completed." Always read from masters. -
commandTimeout: 15000— Prevents workers from hanging forever on a truly dead connection, but must be high enough to not timeout legitimate slow operations (likeO(log N)operations on large sorted sets).
Connection Pooling
Every Queue, Worker, and QueueEvents instance creates at least one Redis connection. Workers also create a duplicate "blocking" connection internally. In a multi-tenant service with 100 tenants, that's potentially 300+ connections.
Strategy A: Shared Cluster with Lazy Connections
const cluster = new Cluster(nodes, {
redisOptions: {
maxRetriesPerRequest: null,
lazyConnect: true, // Don't connect until explicitly told to
},
enableOfflineQueue: true,
});
// Explicitly connect once, then share everywhere
await cluster.connect();
Strategy B: Connection Pool Wrapper
import { Cluster } from "ioredis";
import type { ClusterOptions, NodeRole } from "ioredis";
class ClusterPool {
private clusters: Cluster[] = [];
private index = 0;
constructor(nodeGroups: { host: string; port: number }[][], options: ClusterOptions) {
for (const nodes of nodeGroups) {
this.clusters.push(new Cluster(nodes, options));
}
}
getCluster(): Cluster {
const c = this.clusters[this.index];
this.index = (this.index + 1) % this.clusters.length;
return c;
}
async closeAll(): Promise<void> {
await Promise.all(this.clusters.map((c) => c.quit()));
}
}
Worker Connection Storm (Issue #4138)
Known issue: When 80+ Workers share one ioredis Cluster in a single Node.js process, each Worker internally calls cluster.duplicate(). Each duplicate triggers a full cold handshake with all cluster nodes, causing a connection storm (600–1300 connections per minute) and potentially wedging blocking clients.
Mitigations (as of BullMQ v5.76.6+):
- Upgrade to BullMQ v5.76.6 or later (includes "reconnect wedged blocking cluster clients" fix)
- Use fewer Workers with higher
concurrencyinstead of many Workers - Use process-level sharding — one Node.js process per worker group — to limit duplicates per process
- Watch for the
replaceBlockingConnectionAfteroption in future versions
Graceful Shutdown with Multiple Queues
BullMQ connections prevent Node.js from exiting if not properly closed. You must explicitly close all Workers, Queues, and the underlying Redis connection in the correct order:
import { Cluster } from "ioredis";
import { Queue, Worker } from "bullmq";
class GracefulShutdownManager {
private workers: Worker[] = [];
private queues: Queue[] = [];
private cluster: Cluster;
constructor(cluster: Cluster) {
this.cluster = cluster;
}
registerWorker(worker: Worker): void {
this.workers.push(worker);
}
registerQueue(queue: Queue): void {
this.queues.push(queue);
}
async shutdown(signal: string): Promise<void> {
console.log(`Received ${signal}. Draining workers...`);
// Step 1: Pause all workers — stop accepting new jobs
await Promise.all(this.workers.map((w) => w.pause(true)));
// Step 2: Close workers with grace period
const closeTimeout = 30000; // 30s max for in-flight jobs
await Promise.all(
this.workers.map((w) =>
Promise.race([
w.close(),
new Promise((_, reject) =>
setTimeout(() => reject(new Error("Worker close timeout")), closeTimeout)
),
]).catch((err) => console.warn("Worker close warning:", err.message))
)
);
// Step 3: Close queues
await Promise.all(this.queues.map((q) => q.close()));
// Step 4: Close cluster connection
await this.cluster.quit();
console.log("Graceful shutdown complete");
process.exit(0);
}
registerSignalHandlers(): void {
for (const signal of ["SIGTERM", "SIGINT", "SIGQUIT"]) {
process.on(signal, () => this.shutdown(signal));
}
}
}
Failover Handling
When a cluster node fails, ioredis automatically discovers the new topology via MOVED redirects and slot refresh. However, BullMQ Workers with blocking commands may hang if the blocking connection was to the failed node. The mitigation is twofold:
- Set
clusterRetryStrategyandcommandTimeoutto fail fast and reconnect - Upgrade to BullMQ v5.75.2+ which includes improved cluster
waitUntilReadyhandling
During failover, monitor for:
- Spikes in
CROSSSLOTerrors during resharding (keys migrating between nodes) - Worker reconnection storms as all blocking clients re-establish
- Queue throughput drops during slot migration (mitigated by hash tags keeping each queue's keys together)
Redis Cluster Resizing
Adding or removing shards rebalances slots across nodes. Thanks to hash tags, BullMQ queues migrate as a unit — all keys with the same {tag} move together. During migration, some keys may be temporarily unavailable, causing brief queue pauses. Recommendation: resize during low-traffic periods and monitor queue depth during migration. BullMQ discussion #3371 confirms resizing is generally safe but should be tested in staging first.
Common Pitfalls and How to Avoid Them
| Pitfall | Symptom | Fix |
|---|---|---|
| Same hash tag for all queues | All queue data on one cluster node | Use different hash tags per queue/tenant |
keyPrefix in ioredis |
"Missing lock for job X" errors | Remove keyPrefix; use BullMQ prefix option |
Finite maxRetriesPerRequest on Workers |
Worker crashes randomly | Set maxRetriesPerRequest: null |
| Not passing prefix to Worker | CROSSSLOT errors from Worker | Always pass same prefix to Queue, Worker, QueueEvents |
| Too many Workers sharing one Cluster | Connection storm, wedged blocking clients | Upgrade to v5.76.6+; limit Workers per process; use higher concurrency |
scaleReads: 'slave' |
Stale job state, race conditions | Set scaleReads: 'master' |
| Missing graceful shutdown | Process hangs on exit; orphaned connections | Implement proper close() chain: pause → close workers → close queues → quit cluster |
Monitoring Redis Cluster with QueueHub
Running a multi-queue, multi-tenant BullMQ deployment on Redis Cluster creates a new challenge: visibility. With queues distributed across multiple cluster nodes, tracking job states, worker health, and queue depth becomes significantly harder than with a single Redis instance.
That's where QueueHub comes in. QueueHub connects to your Redis Cluster via the same ioredis Cluster connection pattern described in this guide. The prefix option is configurable in QueueHub's connection settings to match your hash-tag prefixes — so whether you're using {queue:email} or {t:tenant-123}, QueueHub discovers all your queues automatically.
What QueueHub gives you:
- Real-time queue monitoring — waiting, active, completed, failed, delayed counts for every queue
- Job inspection — drill into individual job data, attempts, and stack traces
- Multi-tenant dashboard — see per-tenant queue health at a glance
- Worker health — live view of worker status across all cluster nodes
- Works transparently with Redis Cluster — hash tags ensure LLEN/ZCARD operations resolve correctly
Ready to see your Redis Cluster queues in action? Try QueueHub for real-time queue monitoring, job inspection, and multi-tenant dashboards.
Conclusion
Key Takeaways
- Redis Cluster is production-viable with BullMQ if you use hash tags (
{prefix}) to keep each queue's keys together - Choose your sharding strategy based on tenant isolation requirements vs. operational simplicity
- Always apply the same
prefixoption to Queue, Worker, and QueueEvents - Never use ioredis
keyPrefixwith BullMQ — use BullMQ's ownprefixoption - Configure
maxRetriesPerRequest: nullandenableReadyCheck: falsefor Workers - Plan for connection storms with large worker pools — use BullMQ v5.76.6+
- Implement proper graceful shutdown — pause workers, close queues, quit cluster
When Redis Cluster Isn't the Answer
- For high-throughput single-queue workloads, consider DragonflyDB (Redis-compatible, multi-threaded, works with BullMQ)
- For under 10 GB data with HA needs, Redis Sentinel may be simpler to operate
- For extreme scale (>100k jobs/second), consider partitioning across multiple independent Redis instances
Further Reading
- BullMQ Docs: Redis Cluster
- BullMQ Docs: Connections
- BullMQ Docs: Reusing Redis Connections
- BullMQ Pro Docs: Groups
- BullMQ Changelog v5.76.6
- BullMQ GitHub Issue #4138 — Worker bclient cluster duplicate wedge
- BullMQ GitHub Issue #1232 — Redis Cluster CROSSSLOT error
- Redis Cluster Spec — Hash Tags
- ioredis Cluster Documentation
Related Articles
Best BullMQ Dashboard Alternatives in 2026: A Comprehensive Comparison
Comparing every BullMQ UI option side by side: Bull Board, Arena, Taskforce, QueueHub, and raw redis-cli. Feature matrices, pricing, pros and cons, and recommendations for every team size.
QueueHub vs pg-boss: Redis vs PostgreSQL as a Job Queue Backend
pg-boss uses PostgreSQL as a Node.js job queue, while BullMQ (monitored by QueueHub) uses Redis. We compare the two approaches across throughput, persistence, transactional queues, deployment, and when transactional enqueueing matters.
QueueHub vs Temporal: Job Queues vs Workflow Orchestration
Temporal is a workflow orchestration platform — a different category from Redis-backed job queues. We compare Temporal.io against BullMQ + QueueHub, covering complexity, use cases, durability, observability, and when to choose each.