·QueueHub Team·18 min read

Scaling BullMQ with Redis Cluster: A Production Guide

BullMQRedis Clusterscalingmulti-tenantproduction-deploymentdistributed-queueshash-tagioredis

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 Cluster object
  • 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.id are 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 like 20 may be preferable to fail fast.

  • enableReadyCheck: false — ioredis's ready check sends INFO on 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 (like O(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 concurrency instead of many Workers
  • Use process-level sharding — one Node.js process per worker group — to limit duplicates per process
  • Watch for the replaceBlockingConnectionAfter option 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:

  1. Set clusterRetryStrategy and commandTimeout to fail fast and reconnect
  2. Upgrade to BullMQ v5.75.2+ which includes improved cluster waitUntilReady handling

During failover, monitor for:

  • Spikes in CROSSSLOT errors 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

  1. Redis Cluster is production-viable with BullMQ if you use hash tags ({prefix}) to keep each queue's keys together
  2. Choose your sharding strategy based on tenant isolation requirements vs. operational simplicity
  3. Always apply the same prefix option to Queue, Worker, and QueueEvents
  4. Never use ioredis keyPrefix with BullMQ — use BullMQ's own prefix option
  5. Configure maxRetriesPerRequest: null and enableReadyCheck: false for Workers
  6. Plan for connection storms with large worker pools — use BullMQ v5.76.6+
  7. 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

Related Articles