Benchmarking & Optimizing BullMQ Throughput on Redis Cluster
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:
- Use dedicated hardware or cloud instances — no noisy neighbours
- Warm up by running 5,000+ jobs first, then discard those measurements (this absorbs Lua script caching, connection establishment, and JIT warmup)
- Measure over at least 30 seconds of stable throughput
- Run each test 5 times and report the median run
- 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
Clusterinstances for producer and worker avoid connection sharing biasing results scaleReads: 'slave'distributes slot-map reads to replicasobliteratebetween 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:
- Measure baseline — Run the benchmark harness against your actual cluster topology
- Right-size payloads — Audit job data; move anything over 1 KB to external storage
- Hash-tag distribution — Check that queues with
{prefix}tags are spread across all primaries - Tune worker concurrency — Start at 50 concurrency, adjust based on your work function's CPU profile
- Scale horizontally — Add workers up to the Redis cluster's CPU ceiling, then add cluster primaries
- Monitor slot balance — Use the health monitor to detect hot-spotted primaries
- 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.
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.