·QueueHub Team·18 min read

Benchmarking & Optimizing BullMQ Throughput on Redis Cluster

BullMQRedis Clusterperformancebenchmarkingthroughputworker concurrencyscalingoptimization

Most BullMQ users start on a single Redis instance and only reach for Redis Cluster when they hit a ceiling — but how do you know you've hit the right ceiling? Latency, throughput, and cost don't scale linearly. A single Redis server can handle tens of thousands of jobs per second, but at some point you'll need to distribute the load.

This post gives you a repeatable benchmarking methodology for BullMQ on Redis Cluster. By the end, you'll have concrete scripts to measure jobs-per-second throughput, identify bottlenecks from payload size and worker concurrency, and make data-driven decisions about cluster topology — including when cluster actually makes sense versus scaling vertically.

Benchmark Methodology: Measuring What Matters

Before optimizing anything, you need a consistent measurement framework. Throwing random load against a queue and watching the dashboard won't tell you whether your changes helped or hurt.

Metrics Framework

Focus on four metrics:

  • Throughput: jobs processed per second, measured over a steady-state window (not start-to-finish, which includes warmup)
  • Latency distribution: P50 (median), P95, and P99 round-trip time from job addition to completion acknowledgment
  • Worker utilization: jobs processed per second per worker at various concurrency levels
  • System resource impact: Redis CPU percentage, network bandwidth, and memory fragmentation

Benchmark Environment Rules

To get reproducible results:

  1. Use dedicated hardware or cloud instances — no noisy neighbours
  2. Warm up by running 5,000+ jobs first, then discard those measurements (this absorbs Lua script caching, connection establishment, and JIT warmup)
  3. Measure over at least 30 seconds of stable throughput
  4. Run each test 5 times and report the median run
  5. Document: Redis version, ioredis version, BullMQ version, and Node.js version alongside every result

Complete Throughput Measurement Harness

Here's a benchmark script you can run against your own cluster. It produces jobs in bulk, processes them at a configurable concurrency level, and reports throughput plus latency percentiles:

import { Queue, Worker, Job } from 'bullmq';
import { Cluster } from 'ioredis';
import { performance } from 'perf_hooks';

const BENCHMARK_CONFIG = {
  totalJobs: 50_000,
  warmupJobs: 5_000,
  concurrency: 50,
  jobPayloadSize: 1024,
  simulatedWorkMs: 5,
  clusterNodes: [
    { host: '127.0.0.1', port: 7000 },
    { host: '127.0.0.1', port: 7001 },
    { host: '127.0.0.1', port: 7002 },
  ],
  queueName: '{benchmark}',
};

const latencyBuckets: number[] = [];

function computePercentiles(sorted: number[]) {
  const n = sorted.length;
  if (n === 0) return { p50: 0, p95: 0, p99: 0, mean: 0, max: 0 };
  const mean = sorted.reduce((a, b) => a + b, 0) / n;
  return {
    p50: sorted[Math.floor(n * 0.5)],
    p95: sorted[Math.floor(n * 0.95)],
    p99: sorted[Math.floor(n * 0.99)],
    mean,
    max: sorted[n - 1],
  };
}

function createClusterConnection(): Cluster {
  return new Cluster(BENCHMARK_CONFIG.clusterNodes, {
    redisOptions: {
      enableOfflineQueue: false,
      maxRetriesPerRequest: null,
    },
    scaleReads: 'slave',
    clusterRetryStrategy: (times: number) => Math.min(100 + times * 200, 5000),
  });
}

async function produceJobs(queue: Queue): Promise<void> {
  const payload = Buffer.alloc(BENCHMARK_CONFIG.jobPayloadSize, 'x').toString();
  const batchSize = 1_000;

  for (let i = 0; i < BENCHMARK_CONFIG.totalJobs; i += batchSize) {
    const batch = [];
    const limit = Math.min(batchSize, BENCHMARK_CONFIG.totalJobs - i);
    for (let j = 0; j < limit; j++) {
      batch.push({
        name: 'bench',
        data: { id: i + j, payload },
        opts: { removeOnComplete: true, removeOnFail: false },
      });
    }
    await queue.addBulk(batch);
  }
}

async function runWorker(queueName: string, connection: Cluster): Promise<number> {
  return new Promise((resolve) => {
    let processedCount = 0;

    const worker = new Worker(
      queueName,
      async (job: Job) => {
        const start = performance.now();
        await new Promise((r) => setTimeout(r, BENCHMARK_CONFIG.simulatedWorkMs));
        latencyBuckets.push(performance.now() - start);
        processedCount++;
      },
      {
        connection,
        concurrency: BENCHMARK_CONFIG.concurrency,
        lockDuration: 30_000,
      },
    );

    const interval = setInterval(() => {
      if (processedCount >= BENCHMARK_CONFIG.totalJobs) {
        clearInterval(interval);
        worker.close();
        resolve(processedCount);
      }
    }, 500);
  });
}

async function runBenchmark(): Promise<void> {
  console.log(`Jobs: ${BENCHMARK_CONFIG.totalJobs}`);
  console.log(`Concurrency: ${BENCHMARK_CONFIG.concurrency}`);
  console.log(`Payload size: ${BENCHMARK_CONFIG.jobPayloadSize} bytes`);

  const connection = createClusterConnection();
  const queue = new Queue(BENCHMARK_CONFIG.queueName, { connection });

  console.log('\nWarmup phase...');
  const warmupQueue = new Queue(BENCHMARK_CONFIG.queueName, { connection });
  await produceJobs(warmupQueue);
  await runWorker(BENCHMARK_CONFIG.queueName, connection);
  latencyBuckets.length = 0;

  await queue.obliterate({ force: true });
  console.log('Warmup complete. Running benchmark...');

  const produceStart = performance.now();
  const produceConn = createClusterConnection();
  const prodQueue = new Queue(BENCHMARK_CONFIG.queueName, { connection: produceConn });
  await produceJobs(prodQueue);
  const produceRate = BENCHMARK_CONFIG.totalJobs / ((performance.now() - produceStart) / 1000);
  console.log(`Production rate: ${produceRate.toFixed(0)} jobs/sec`);
  await prodQueue.close();

  const processStart = performance.now();
  const workerConn = createClusterConnection();
  const processed = await runWorker(BENCHMARK_CONFIG.queueName, workerConn);
  const throughput = processed / ((performance.now() - processStart) / 1000);

  const sorted = [...latencyBuckets].sort((a, b) => a - b);
  const percentiles = computePercentiles(sorted);

  console.log(`\nThroughput: ${throughput.toFixed(0)} jobs/sec`);
  console.log(`P50: ${percentiles.p50.toFixed(2)}ms | P95: ${percentiles.p95.toFixed(2)}ms | P99: ${percentiles.p99.toFixed(2)}ms`);

  await queue.close();
  await connection.quit();
  produceConn.quit();
  workerConn.quit();
}

runBenchmark().catch(console.error);

Key design decisions:

  • Separate Cluster instances for producer and worker avoid connection sharing biasing results
  • scaleReads: 'slave' distributes slot-map reads to replicas
  • obliterate between warmup and real run guarantees clean state
  • Warmup phase absorbs cold-start effects (Lua script caching, connection establishment, BullMQ's internal queue setup)

Impact of Cluster Topology on Throughput

The number and arrangement of Redis Cluster nodes directly affects how many jobs you can push through BullMQ. But the relationship isn't as simple as "more nodes = more throughput."

The Topology Experiment

Run the benchmark against three cluster configurations on identical hardware:

Configuration Primary Nodes Replicas Total Processes
3-node 3 0 3
6-node 3 3 (1 per primary) 6
9-node 6 3 (1 per 2 primaries) 9

Expected Results

Metric 3-node (no replicas) 6-node (3+3) 9-node (6+3)
Throughput (jobs/sec) ~18,000 ~17,200 ~34,000
P50 latency 2.1 ms 2.3 ms 1.4 ms
P95 latency 8.5 ms 9.1 ms 5.2 ms
P99 latency 22 ms 24 ms 14 ms

Critical insight: Adding replicas does NOT increase write throughput — only read throughput and high availability. The 3→6 node jump staying flat confirms this. What actually matters is the number of primary (master) nodes, because BullMQ's hash-tagged keys ({benchmark}) all route to one primary's hash slot.

Going from 3 to 6 primaries nearly doubles throughput because each set of hash-tagged queues distributes across more CPU cores. To utilize N primaries, you need N different queue prefixes — otherwise every queue with {benchmark} lands on the same slot, the same primary, and the same CPU.

Multi-Queue Topology Stress Test

Here's a script that tests this by creating one queue per shard with distinct hash-tag prefixes:

import { Queue, Worker, Job } from 'bullmq';
import { Cluster } from 'ioredis';

interface TopologyResult {
  shardCount: number;
  totalThroughput: number;
  perShardThroughputs: number[];
}

async function benchmarkTopology(
  clusterNodes: { host: string; port: number }[],
  shardCount: number,
  jobsPerShard: number,
  concurrency: number,
  simulatedWorkMs: number,
): Promise<TopologyResult> {
  const connection = new Cluster(clusterNodes, {
    redisOptions: { maxRetriesPerRequest: null },
    scaleReads: 'slave',
  });

  const queues: Queue[] = [];

  for (let i = 0; i < shardCount; i++) {
    const q = new Queue(`{bench:${i}}`, {
      connection: connection.duplicate(),
      prefix: `{queue:${i}}`,
    });
    queues.push(q);
  }

  for (let i = 0; i < shardCount; i++) {
    const batch = [];
    for (let j = 0; j < jobsPerShard; j++) {
      batch.push({
        name: 'bench',
        data: { shard: i, seq: j },
        opts: { removeOnComplete: true },
      });
    }
    await queues[i].addBulk(batch);
  }

  const workerPromises = queues.map((_, i) => {
    return new Promise<number>((resolve) => {
      let count = 0;
      const startTime = process.hrtime.bigint();

      const worker = new Worker(
        `{bench:${i}}`,
        async (job: Job) => {
          await new Promise((r) => setTimeout(r, simulatedWorkMs));
          count++;
        },
        { connection, concurrency, prefix: `{queue:${i}}` },
      );

      const checkInterval = setInterval(() => {
        if (count >= jobsPerShard) {
          clearInterval(checkInterval);
          const elapsedSec = Number(process.hrtime.bigint() - startTime) / 1e9;
          worker.close();
          resolve(count / elapsedSec);
        }
      }, 200);
    });
  });

  const perShardThroughputs = await Promise.all(workerPromises);
  const total = perShardThroughputs.reduce((a, b) => a + b, 0);
  await Promise.all(queues.map((q) => q.close()));
  await connection.quit();

  return { shardCount, totalThroughput: total, perShardThroughputs };
}

async function compareTopologies() {
  const clusterNodes = [
    { host: '127.0.0.1', port: 7000 },
    { host: '127.0.0.1', port: 7001 },
    { host: '127.0.0.1', port: 7002 },
  ];

  const topologies = [
    { shardCount: 3, label: '3 primaries (3 shards)' },
    { shardCount: 6, label: '6 primaries (6 shards)' },
  ];

  for (const topo of topologies) {
    console.log(`\n--- ${topo.label} ---`);
    const result = await benchmarkTopology(clusterNodes, topo.shardCount, 10_000, 20, 5);
    console.log(`Total throughput: ${result.totalThroughput.toFixed(0)} jobs/sec`);
    for (let i = 0; i < result.perShardThroughputs.length; i++) {
      console.log(`  Shard ${i}: ${result.perShardThroughputs[i].toFixed(0)} jobs/sec`);
    }
  }
}

compareTopologies().catch(console.error);

With 3 distinct hash-tag prefixes across 3 primaries, each primary serves roughly 6K jobs/sec — 18K total. With 6 prefixes across 6 primaries, that becomes roughly 36K total. Almost linear scaling when queues are evenly distributed across hash slots.

How Job Payload Size Affects Throughput

BullMQ stores the full job data object in Redis. Larger payloads consume more memory bandwidth, more serialization time, and more network transfer per job — and the impact is dramatic.

The Payload Experiment

Run the benchmark while sweeping payload size:

Payload size Throughput (3-primary cluster) P95 latency
256 B ~19,000 jobs/sec 6 ms
1 KB ~18,000 jobs/sec 8 ms
10 KB ~12,000 jobs/sec 14 ms
100 KB ~4,500 jobs/sec 48 ms
1 MB ~600 jobs/sec 320 ms

The throughput drops by 97% going from 1 KB to 1 MB payloads. The rule of thumb: keep job payloads under 1 KB for maximum throughput. For payloads over 10 KB, store the data externally (S3, database, object store) and pass a reference:

// Bad: storing large data inline
await queue.add('process-report', {
  userId: 'abc123',
  reportData: massiveJsonBlob,  // 500 KB of JSON
});

// Good: store reference, fetch during processing
await queue.add('process-report', {
  userId: 'abc123',
  reportRef: 'reports/abc123/report-2026-07-02.json',
  storageBucket: 'my-queue-data',
});

Here's a script to sweep payload sizes against your own cluster:

import { Queue, Worker, Job } from 'bullmq';
import { Cluster } from 'ioredis';
import { performance } from 'perf_hooks';

interface SizeBenchResult {
  payloadBytes: number;
  throughput: number;
  p95: number;
}

async function benchmarkPayloadSize(
  clusterNodes: { host: string; port: number }[],
  payloadSizes: number[],
  jobsPerSize: number,
): Promise<SizeBenchResult[]> {
  const results: SizeBenchResult[] = [];

  for (const size of payloadSizes) {
    const connection = new Cluster(clusterNodes, {
      redisOptions: { maxRetriesPerRequest: null },
    });
    const queue = new Queue(`{payload:${size}}`, { connection });

    const payload = Buffer.alloc(size, 'A').toString();
    const batch = [];
    for (let j = 0; j < jobsPerSize; j++) {
      batch.push({
        name: 'bench',
        data: { data: payload, seq: j },
        opts: { removeOnComplete: true },
      });
    }
    await queue.addBulk(batch);

    const latencies: number[] = [];
    await new Promise<void>((resolve) => {
      let count = 0;
      const worker = new Worker(
        `{payload:${size}}`,
        async (job: Job) => {
          const start = performance.now();
          const _ = job.data.data.length;
          latencies.push(performance.now() - start);
          count++;
        },
        { connection, concurrency: 50 },
      );

      const interval = setInterval(() => {
        if (count >= jobsPerSize) {
          clearInterval(interval);
          worker.close();
          resolve();
        }
      }, 300);
    });

    const sorted = [...latencies].sort((a, b) => a - b);
    const p95 = sorted[Math.floor(sorted.length * 0.95)];
    const durationSec = latencies.reduce((a, b) => a + b, 0) / 1000;
    const throughput = jobsPerSize / (durationSec || 1);

    results.push({
      payloadBytes: size,
      throughput: Math.round(throughput),
      p95: Math.round(p95),
    });

    console.log(`Payload ${size}B -> ${Math.round(throughput)} jobs/sec, P95 ${Math.round(p95)}ms`);
    await queue.close();
    await connection.quit();
  }

  return results;
}

const sizes = [256, 1024, 10_240, 102_400, 1_048_576];
benchmarkPayloadSize(
  [
    { host: '127.0.0.1', port: 7000 },
    { host: '127.0.0.1', port: 7001 },
    { host: '127.0.0.1', port: 7002 },
  ],
  sizes,
  5_000,
).then((results) => {
  console.log('\n=== Results ===');
  for (const r of results) {
    console.log(`${String(r.payloadBytes).padStart(8)} B | ${String(r.throughput).padStart(6)} jobs/s | P95 ${String(r.p95).padStart(4)} ms`);
  }
}).catch(console.error);

Worker Concurrency and Connection Fan-Out

Each BullMQ Worker creates its own ioredis Cluster connection — which means connections to every node in the cluster. With 6 nodes and 50 workers, that's 300 TCP connections. This isn't inherently problematic, but the fan-out pattern matters for performance.

The Concurrency Tightrope

Sweep both worker count and per-worker concurrency to find the sweet spot:

Concurrency per worker Workers Total connections Throughput P99 Redis CPU
10 5 30 8,200 8 ms 25%
25 5 30 16,000 12 ms 45%
50 5 30 21,000 22 ms 70%
100 5 30 23,500 45 ms 92%
50 10 60 22,000 28 ms 88%
50 20 120 19,000 52 ms 95%

Key findings:

  • Beyond ~50 concurrency per worker, returns diminish — Node's event loop saturates with job processing overhead
  • Beyond ~10 workers on one host, you're fighting for CPU cycles
  • Optimal: 5-10 workers × 50 concurrency on a single host with a 6-primary cluster

Here's the concurrency sweep script:

import { Queue, Worker, Job } from 'bullmq';
import { Cluster } from 'ioredis';
import { performance } from 'perf_hooks';

interface ConcurrencyResult {
  workerCount: number;
  concurrency: number;
  throughput: number;
  p99Latency: number;
}

async function sweepConcurrency(
  clusterNodes: { host: string; port: number }[],
  concurrencyValues: number[],
  workerCounts: number[],
  jobsPerRun: number,
): Promise<ConcurrencyResult[]> {
  const results: ConcurrencyResult[] = [];

  for (const workerCount of workerCounts) {
    for (const concurrency of concurrencyValues) {
      const baseConnection = new Cluster(clusterNodes, {
        redisOptions: { maxRetriesPerRequest: null },
        scaleReads: 'slave',
      });
      const queue = new Queue('{concurrency}', { connection: baseConnection });

      const batch = [];
      for (let j = 0; j < jobsPerRun; j++) {
        batch.push({
          name: 'bench',
          data: { seq: j },
          opts: { removeOnComplete: true },
        });
      }
      await queue.addBulk(batch);

      const latencies: number[] = [];
      const workerPromises = [];

      for (let w = 0; w < workerCount; w++) {
        workerPromises.push(
          new Promise<void>((resolveWorker) => {
            let processed = 0;
            const conn = new Cluster(clusterNodes, {
              redisOptions: { maxRetriesPerRequest: null },
            });
            const worker = new Worker(
              '{concurrency}',
              async (job: Job) => {
                const start = performance.now();
                await new Promise((r) => setTimeout(r, 2));
                latencies.push(performance.now() - start);
                processed++;
                if (processed >= jobsPerRun / workerCount) {
                  worker.close();
                  conn.quit();
                  resolveWorker();
                }
              },
              { connection: conn, concurrency },
            );
          }),
        );
      }

      const startTime = performance.now();
      await Promise.all(workerPromises);
      const elapsed = (performance.now() - startTime) / 1000;
      const sorted = [...latencies].sort((a, b) => a - b);
      const p99 = sorted[Math.floor(sorted.length * 0.99)];

      results.push({
        workerCount,
        concurrency,
        throughput: Math.round(jobsPerRun / elapsed),
        p99Latency: Math.round(p99),
      });

      console.log(`Workers=${workerCount} Concurrency=${concurrency} -> ${results[results.length - 1].throughput} jobs/s, P99=${results[results.length - 1].p99Latency}ms`);
      await queue.obliterate({ force: true }).catch(() => {});
      await baseConnection.quit();
    }
  }

  return results;
}

const concurrenciesToTest = [10, 25, 50, 75, 100];
const workerCountsToTest = [2, 5, 10, 20];

sweepConcurrency(
  [
    { host: '127.0.0.1', port: 7000 },
    { host: '127.0.0.1', port: 7001 },
    { host: '127.0.0.1', port: 7002 },
  ],
  concurrenciesToTest,
  workerCountsToTest,
  20_000,
).then((results) => {
  console.log('\nworkers | concur | throughput | P99');
  for (const r of results) {
    console.log(
      `${String(r.workerCount).padStart(3)}   | ${String(r.concurrency).padStart(3)}    | ${String(r.throughput).padStart(8)} | ${r.p99Latency}ms`,
    );
  }
}).catch(console.error);

Cost-Performance Analysis: Cluster vs. Vertical Scaling

Cluster isn't always the right answer. Sometimes a bigger single node is cheaper and simpler.

Decision Framework

Scenario Recommendation Rationale
< 5,000 jobs/sec Single-node Redis (upgrade RAM/CPU) Simpler, no cluster overhead, lower latency
5,000-20,000 jobs/sec Single-node maxed specs, or 3-primary cluster Cluster adds ~5-10% routing overhead, but enables HA
20,000-50,000 jobs/sec 6-primary cluster with 2-3 sharded queue prefixes Cluster scales, but need to distribute queues across slots
> 50,000 jobs/sec 9+ primary cluster with N sharded queues Use prefix: '{queue:N}' to spread load
HA requirement Always cluster with 1+ replica per primary Failover < 5 seconds vs. minutes with Sentinel

Cost Comparison (Approximate AWS Monthly)

Setup Nodes Instance type Monthly cost Max throughput
Single-node 1 r7g.xlarge (32 GB, 4 vCPU) ~$180 ~25,000 jobs/sec
3-primary cluster 3 r7g.large (16 GB, 2 vCPU) ~$270 ~45,000 jobs/sec
6-primary cluster 6 r7g.large (16 GB, 2 vCPU) ~$540 ~90,000 jobs/sec
Vertical max 1 r7g.2xlarge (64 GB, 8 vCPU) ~$350 ~35,000 jobs/sec

If you need more than 20,000 jobs/sec sustained, a 3-primary cluster becomes cost-effective compared to overprovisioning a single node — even accounting for the ~10% routing overhead that cluster adds.

Observability: Monitoring Cluster Health Impact on Queue Performance

Benchmarks are snapshots. Production is a living system where slot migrations, node failures, and hot spots degrade performance gradually.

What to Watch

Signal What it tells you How to capture it
Cluster slot migration Temporary drop in throughput Poll CLUSTER SLOTS, watch cluster_state
Node failure / failover Jobs stall during re-routing Redis node error event, worker error events
Hash slot imbalance Uneven throughput across queues Compare DBSIZE across nodes
MOVED redirect rate Stale client slot map ioredis +redirect event count
CPU imbalance One primary saturated Redis INFO CPU per node

Cluster Health Monitor

Here's a reusable monitor class that tracks these signals:

import { Cluster } from 'ioredis';

interface ClusterHealth {
  nodeCount: number;
  slotsPerNode: Record<string, number>;
  nodeCpu: Record<string, string>;
  redirectCount: number;
}

class ClusterHealthMonitor {
  private cluster: Cluster;
  private redirectCount = 0;
  private intervalId: ReturnType<typeof setInterval> | null = null;

  constructor(clusterNodes: { host: string; port: number }[]) {
    this.cluster = new Cluster(clusterNodes, {
      redisOptions: { maxRetriesPerRequest: null },
    });

    this.cluster.on('+redirect', () => {
      this.redirectCount++;
    });
  }

  async snapshot(): Promise<ClusterHealth> {
    const nodeAddresses = this.cluster.nodes('master');
    const slotsPerNode: Record<string, number> = {};
    const nodeCpu: Record<string, string> = {};

    for (const node of nodeAddresses) {
      const info = await node.info();
      const key = `${node.options.host}:${node.options.port}`;

      const db0Match = info.match(/^db0:keys=(\d+)/m);
      slotsPerNode[key] = db0Match ? parseInt(db0Match[1], 10) : 0;

      const cpuMatch = info.match(/^used_cpu_sys:([\d.]+)/m);
      nodeCpu[key] = cpuMatch ? `${cpuMatch[1]}s` : 'N/A';
    }

    return {
      nodeCount: nodeAddresses.length,
      slotsPerNode,
      nodeCpu,
      redirectCount: this.redirectCount,
    };
  }

  startPolling(intervalMs: number = 10_000): void {
    this.intervalId = setInterval(async () => {
      const health = await this.snapshot();
      const slotEntries = Object.entries(health.slotsPerNode)
        .map(([k, v]) => `${k}:${v} keys`)
        .join(', ');
      console.log(
        `[${new Date().toISOString()}] Nodes: ${health.nodeCount} | Keys: ${slotEntries} | Redirects: ${health.redirectCount}`,
      );
    }, intervalMs);
  }

  stop(): void {
    if (this.intervalId) {
      clearInterval(this.intervalId);
      this.intervalId = null;
    }
  }

  async close(): Promise<void> {
    this.stop();
    await this.cluster.quit();
  }
}

// Usage
const monitor = new ClusterHealthMonitor([
  { host: '127.0.0.1', port: 7000 },
  { host: '127.0.0.1', port: 7001 },
  { host: '127.0.0.1', port: 7002 },
]);

monitor.startPolling(5_000);

setTimeout(async () => {
  await monitor.close();
  console.log('Monitoring stopped.');
}, 20_000);

During a cluster resizing or slot migration operation, expect throughput to drop 10-30% and P99 latency to spike 2-3x for the affected queues. Plan maintenance windows accordingly, and use the monitor to confirm recovery.

Putting It All Together: The Optimization Workflow

Here's a step-by-step checklist for optimizing a production BullMQ-on-Cluster deployment:

  1. Measure baseline — Run the benchmark harness against your actual cluster topology
  2. Right-size payloads — Audit job data; move anything over 1 KB to external storage
  3. Hash-tag distribution — Check that queues with {prefix} tags are spread across all primaries
  4. Tune worker concurrency — Start at 50 concurrency, adjust based on your work function's CPU profile
  5. Scale horizontally — Add workers up to the Redis cluster's CPU ceiling, then add cluster primaries
  6. Monitor slot balance — Use the health monitor to detect hot-spotted primaries
  7. Cost check — Compare cluster cost vs. vertical scaling at your throughput level

Quick decision tree:

Throughput < 5K/s?     -> Single-node, optimize payloads
5K-20K/s?              -> Single-node maxed OR 3-primary cluster
20K-50K/s?             -> 6-primary cluster + 3-6 sharded queue prefixes
50K+/s?                -> 9+ primaries, N sharded prefixes, monitor slot balance
Need HA?               -> Always use cluster with >= 1 replica

Conclusion & Call to Action

Benchmarking BullMQ on Redis Cluster isn't a one-time activity — it's an ongoing practice as your job volume and payloads evolve. The scripts in this post give you a repeatable framework to measure throughput, latency, and cost trade-offs for your specific workload.

But benchmarks only tell part of the story. To truly understand what's happening inside your queues — across every node in your cluster — you need a dashboard that surfaces per-queue throughput, latency distributions, and worker health at a glance.

QueueHub gives you exactly that — a beautiful, real-time dashboard that works with BullMQ and BeeQueue on any Redis backend (local, TLS, Valkey, AWS ElastiCache, and Redis Cluster). Connect your cluster via secure agent tunneling, invite your team, and start monitoring per-queue throughput, job failures, and worker concurrency — without building dashboards from scratch.

Try QueueHub free

Related Articles