Monitoring Redis Queue Health: Latency, Memory & Throughput Guide
Your Redis instance has been silently losing memory for three hours. Jobs are failing. Workers are stalling. Your first sign of trouble? A customer complaint.
If this scenario sounds familiar, you are not alone. Redis powers every BullMQ and BeeQueue deployment, yet its health remains invisible until something breaks. Most teams monitor application-level metrics -- CPU, memory, request rates -- but miss the Redis layer entirely until production goes down.
Redis queue health boils down to three critical dimensions:
- Latency -- blocked commands starving workers
- Memory -- OOM failures or silent data loss from eviction
- Throughput -- backlog growing unbounded when arrival rate exceeds processing rate
In this guide, we will cover Redis-native diagnostic tools like INFO, LATENCY, and SLOWLOG, show you how to analyze memory usage by queue, walk through BullMQ-specific monitoring with getJobCounts() and getMetrics(), and survey the tooling landscape from redis-cli to Queue Hub (QueueHub). By the end, you will have a production-ready monitoring strategy for your Redis-backed queues.
TL;DR for skimmers:
- Never use
MONITORin production (50% throughput loss)- Always set
maxmemory-policy noevictionfor queue Redis instances- Track
used_memory,instantaneous_ops_per_sec, andevicted_keysfromINFO- BullMQ's
getJobCounts()+getMetrics()give you job-level throughput for free- A good dashboard needs three views: Redis health, queue performance, worker status
The Three Pillars of Queue Health
Latency -- The Silent Killer
Redis is single-threaded. One slow command blocks every other command until it finishes. In a queue system, this means workers cannot heartbeat, jobs cannot be fetched, and your entire pipeline grinds to a halt.
Common sources of queue latency include:
- Large job payloads (10MB+ serialization) that saturate the network buffer
- Lua
EVALSHAscripts under load -- BullMQ uses these extensively for atomic operations - BullMQ stall-detection polling at scale, especially with many queues on one Redis instance
BRPOPLPUSHon queues with millions of entries
The insidious thing about latency is that individual commands look fast in isolation, but queuing effects compound. A 5ms command under load can become a 500ms command when the command queue backs up.
How to Measure Latency
redis-cli --latency -- your first diagnostic:
redis-cli --latency -h myredis.example.com -p 6379
# Output: min: 0ms, max: 87ms, avg: 1.23ms (5611 samples)
This shows real-time round-trip time from client to server. Crucially, run this from the same network as your workers, not your laptop. Expected values: < 2ms on same-region, < 10ms cross-region.
redis-cli --intrinsic-latency -- the floor you cannot go below:
redis-cli --intrinsic-latency 100
# Run ON the Redis server -- measures OS/hypervisor baseline noise
Virtualized hosts (EC2 Xen, Nitro, etc.) typically show 5--40ms of intrinsic latency; physical hosts are usually under 200us. If your measured latency is close to the intrinsic latency, it is a network or OS problem, not a Redis problem.
LATENCY family (Redis 2.8.13+):
# Enable -- log any latency spike > 100ms
redis-cli CONFIG SET latency-monitor-threshold 100
# Check what is happening
redis-cli LATENCY LATEST
redis-cli LATENCY HISTORY command
redis-cli LATENCY DOCTOR # Human-readable diagnosis
SLOWLOG -- find the offending commands:
redis-cli CONFIG SET slowlog-log-slower-than 10000 # 10ms threshold
redis-cli SLOWLOG GET 20
redis-cli SLOWLOG LEN
Polling SLOWLOG programmatically with ioredis:
import Redis from "ioredis";
const redis = new Redis({ host: process.env.REDIS_HOST, port: 6379 });
// Set threshold to 100ms
await redis.config("SET", "slowlog-log-slower-than", "100000");
async function pollSlowlog() {
const entries = await redis.slowlog("GET", 20);
const critical = entries.filter((e: any) => e[2] > 1_000_000); // >1s
if (critical.length) {
console.warn(`Ⓒ ${critical.length} critical slow commands:`,
critical.map((e: any) => ({
cmd: e[3].join(" ").substring(0, 200),
duration: `${(e[2] / 1000).toFixed(0)}ms`,
time: new Date(e[1] * 1000).toISOString(),
}))
);
}
}
setInterval(pollSlowlog, 30_000);
The MONITOR Command -- Why It Is Dangerous
redis-cli MONITOR # NEVER do this in production
MONITOR streams every single command to the client. It reduces Redis throughput by approximately 50% because the server spends CPU cycles formatting and sending output for every command. The only legitimate use case is an isolated replica during brief debugging sessions (under 60 seconds).
Safe alternative -- INFO commandstats:
redis-cli INFO commandstats
# cmdstat_get:calls=1234567,usec=8123456,usec_per_cmd=6.59
# cmdstat_evalsha:calls=98765,usec=1234567,usec_per_cmd=12.50
Watch for EVALSHA (BullMQ Lua scripts) and BRPOPLPUSH topping the list.
BullMQ-Specific Latency Signals
- Worker
stalledevent frequency spikes - High
lockDurationmisses -> jobs being re-processed unnecessarily - Queue
activecount > worker concurrency -> stalled or frozen jobs
Key insight: A stalled job is almost always a latency symptom -- the worker could not heartbeat to Redis in time. If you see stalled jobs climbing, check Redis latency before debugging your worker code.
Memory -- The Most Common Failure Mode
Every BullMQ job lives in RAM until explicitly cleaned. When Redis hits maxmemory, writes either fail (under noeviction) or data evaporates (under any eviction policy). For queue workloads, the queue data is the largest, most critical dataset in the instance.
Key INFO Memory Fields
redis-cli INFO memory
| Field | What It Tells You | Alarm Threshold |
|---|---|---|
used_memory |
Bytes allocated by Redis | >80% maxmemory |
used_memory_rss |
Actual physical RAM used | Compare to used_memory |
mem_fragmentation_ratio |
RSS/used_memory efficiency | <1.0 (swapping!) or >1.5 |
maxmemory |
Configured limit (0 = unlimited) | Should never be 0 |
evicted_keys |
Keys evicted since startup | >0/hr is warning |
total_system_memory |
Host total RAM | Used for headroom calc |
Polling memory metrics programmatically:
interface MemoryInfo {
used_memory: number;
used_memory_rss: number;
maxmemory: number;
mem_fragmentation_ratio: number;
evicted_keys: number;
total_system_memory: number;
}
async function getMemoryMetrics(redis: Redis): Promise<MemoryInfo> {
const info = await redis.info("memory");
const parse = (key: string): number => {
const m = info.match(new RegExp(`^${key}:(\d+)`, "m"));
return m ? parseInt(m[1], 10) : 0;
};
return {
used_memory: parse("used_memory"),
used_memory_rss: parse("used_memory_rss"),
maxmemory: parse("maxmemory"),
mem_fragmentation_ratio: parse("mem_fragmentation_ratio") / 100,
evicted_keys: parse("evicted_keys"),
total_system_memory: parse("total_system_memory"),
};
}
Memory Usage by Queue -- Counting Keys
BullMQ stores queue data under the bull:{queueName}:* key pattern. Different job states use different Redis data structures:
| BullMQ Key | Data Type | Purpose |
|---|---|---|
bull:{name}:wait |
LIST | Waiting jobs (FIFO) |
bull:{name}:active |
LIST | Currently processing |
bull:{name}:completed |
SET | Recently completed job IDs |
bull:{name}:failed |
SET | Recently failed job IDs |
bull:{name}:delayed |
ZSET | Delayed jobs (by timestamp) |
bull:{name}:{jobId} |
HASH | Individual job data + state |
bull:{name}:meta |
HASH | Queue metadata/version |
Direct key inspection by type:
async function inspectQueueKeys(redis: Redis, queueName: string) {
const prefix = `bull:${queueName}`;
const [waitLen, activeLen, completedCard, failedCard, delayedCard] =
await Promise.all([
redis.llen(`${prefix}:wait`),
redis.llen(`${prefix}:active`),
redis.scard(`${prefix}:completed`),
redis.scard(`${prefix}:failed`),
redis.zcard(`${prefix}:delayed`),
]);
return { waitLen, activeLen, completedCard, failedCard, delayedCard };
}
Finding stale jobs -- checking TTLs:
BullMQ does not set TTL on most job keys. Jobs with TTLs are typically delayed or rate-limited jobs only:
# Scan for queue job keys and check their TTL
redis-cli --scan --pattern 'bull:*' | head -100 | while read key; do
ttl=$(redis-cli ttl "$key")
mem=$(redis-cli memory usage "$key")
echo "$key | TTL: $ttl | Memory: $mem bytes"
done
Memory-expensive patterns to watch for:
- Completed jobs without
removeOnComplete-- the single most common bloat source - Failed jobs without
removeOnFail-- accumulates silently over time - Large job payloads in HASH fields -- a single 10MB job can block memory for the entire queue
- High-cardinality
completedorfailedsets -- sets of job IDs that never expire
The Eviction Policy Trap
BullMQ's documentation is unequivocal: use noeviction.
# redis.conf
maxmemory 2gb
maxmemory-policy noeviction
| Policy | Effect on Queues | Verdict |
|---|---|---|
noeviction |
Writes fail with OOM -- queue freezes, no data loss | Required |
allkeys-lru |
Redis evicts ANY key -- jobs disappear silently | Data loss |
volatile-lru |
Only keys with TTL -- BullMQ sets few TTLs, so it is ineffective | False safety |
allkeys-random |
Random job loss | Data loss |
volatile-ttl |
Evicts shortest TTL first -- same problem | Unsafe |
Workaround if you cannot use noeviction: Set removeOnComplete aggressively, increase maxmemory, and monitor evicted_keys religiously.
Best Practice: Job Retention Configuration
const queue = new Queue("orders", {
connection,
defaultJobOptions: {
removeOnComplete: { age: 3600 * 24, count: 1000 }, // 24h or 1000
removeOnFail: { age: 3600 * 24 * 7, count: 5000 }, // 7 days
},
});
Memory headroom rule: Always leave 30--50% headroom above your working set for BGSAVE copy-on-write. If your working set is 2GB, set maxmemory to at least 3--4GB.
Enable activedefrag:
activedefrag yes
active-defrag-threshold-lower 10
active-defrag-threshold-upper 100
active-defrag-cycle-min 1
active-defrag-cycle-max 25
Throughput -- Is Your Queue Keeping Up?
Throughput monitoring has three vectors: arrival rate (enqueues per second), processing rate (completions per second), and backlog (waiting + delayed jobs). When arrival exceeds processing over sustained periods, queue depth grows unbounded and memory fills.
BullMQ Built-in Metrics
Enable metrics on the Worker:
import { Worker, MetricsTime } from "bullmq";
const worker = new Worker("email", async (job) => {
await sendEmail(job.data);
}, {
connection,
metrics: {
maxDataPoints: MetricsTime.ONE_WEEK * 2, // 2 weeks of 1-min buckets
},
});
Query via the Queue:
const metrics = await queue.getMetrics("completed", 0, -1);
// {
// data: number[], // jobs completed per minute
// count: number, // total completed
// meta: { count, prevTS, prevCount }
// }
Job Counts Dashboard
async function pollQueueMetrics(queue: Queue) {
const jobCounts = await queue.getJobCounts(
"waiting", "active", "completed", "failed", "delayed", "paused"
);
const waitingRising = jobCounts.waiting > 1000; // Example threshold
const failureRate = (jobCounts.failed / (jobCounts.completed || 1)) * 100;
console.log({
waiting: jobCounts.waiting,
active: jobCounts.active,
completed: jobCounts.completed,
failed: jobCounts.failed,
failureRate: `${failureRate.toFixed(1)}%`,
healthy: failureRate < 5 && !waitingRising,
});
}
Interpreting Job Count Patterns
| Signal | What It Means |
|---|---|
waiting + delayed rising |
Backlog growing -- need more workers or faster processing |
active == concurrency * workers |
All capacity saturated |
active > capacity |
Stalled jobs -- check for worker crashes |
failed > 5% |
Systematic failure -- investigate error types |
delayed growing |
Retry backoff consuming jobs slower than failing |
Prometheus Export
BullMQ provides a built-in Prometheus metrics endpoint:
import http from "http";
import { Queue } from "bullmq";
const queue = new Queue("my-queue", { connection });
const server = http.createServer(async (req, res) => {
if (req.url === "/metrics") {
const metrics = await queue.exportPrometheusMetrics({
env: "production",
});
res.writeHead(200, { "Content-Type": "text/plain" });
res.end(metrics);
}
});
server.listen(3000);
Sample Prometheus output:
# HELP bullmq_job_count Number of jobs in the queue by state
# TYPE bullmq_job_count gauge
bullmq_job_count{queue="my-queue",state="waiting"} 5
bullmq_job_count{queue="my-queue",state="active"} 3
bullmq_job_count{queue="my-queue",state="completed"} 12
bullmq_job_count{queue="my-queue",state="failed"} 2
Redis-Native Monitoring Tools -- Cheat Sheet
INFO Command -- Sections Worth Memorizing
redis-cli INFO memory # Memory usage, fragmentation, evictions
redis-cli INFO stats # connected_clients, total_commands_processed, instantaneous_ops_per_sec
redis-cli INFO clients # connected_clients, maxclients, blocked_clients
redis-cli INFO commandstats # Per-command call count + CPU time
redis-cli INFO keyspace # Per-DB key count + TTL stats
redis-cli INFO persistence # Last BGSAVE, AOF status, RDB changes since last save
redis-cli INFO replication # Role (master/slave), lag, connected replicas
redis-cli INFO cpu # CPU usage by Redis process
redis-cli INFO server # Redis version, uptime, mode
Hit/miss ratio calculation:
redis-cli INFO stats | grep -E "keyspace_(hits|misses)"
# Ratio = keyspace_hits / (keyspace_hits + keyspace_misses)
# >0.8 = healthy cache, but queues are write-heavy -- expect lower
CLIENT LIST -- Watch for Stalled Connections
redis-cli CLIENT LIST
# id=1234 addr=10.0.0.5:54321 fd=8 name= age=1423 idle=0 ...
Key field: omem -- output buffer memory in bytes. A worker with omem > 10MB may have a stalled connection eating Redis RAM. Kill stuck clients with redis-cli CLIENT KILL TYPE normal.
MEMORY USAGE -- Per-Key Memory Profiling
redis-cli MEMORY USAGE bull:email:83
# Returns bytes for a single key
# Scan largest keys by type
redis-cli --bigkeys
redis-cli --memkeys
BullMQ-Specific Monitoring Deep Dive
Auto-Detecting Queues
async function discoverQueues(redis: Redis): Promise<string[]> {
const keys = await redis.keys("bull:*:meta");
return keys.map((k: string) => k.split(":")[1]);
}
Aggregated Queue Health
async function monitorAllQueues(redis: Redis) {
const names = await discoverQueues(redis);
const results = await Promise.all(
names.map(async (name) => {
const q = new Queue(name, { connection: redis });
const counts = await q.getJobCounts();
return { name, ...counts };
})
);
console.table(results);
}
Worker Health -- Heartbeats and Stalls
const worker = new Worker("orders", async (job) => process(job), {
connection,
concurrency: 10,
lockDuration: 60000, // Max time a job can be "active"
stallInterval: 30000, // How often to check for stalled jobs
});
worker.on("completed", (job) => {
// Track per-job completion for processing time histogram
});
worker.on("failed", (job, err) => {
console.error(`Job ${job.id} failed: ${err.message}`);
});
worker.on("stalled", (jobId) => {
console.warn(`Job ${jobId} stalled -- may be re-processed`);
});
How QueueHub uses this: The live workers view shows active workers, their current jobs, heartbeat timestamps, and stalled-job counters -- all driven by these events.
Tooling Landscape
Command-Line Tools
| Tool | Use Case |
|---|---|
redis-cli --latency |
Real-time latency check from worker network |
redis-cli --intrinsic-latency 100 |
OS/hypervisor baseline |
redis-cli --bigkeys / --memkeys |
Find memory hogs by data type |
redis-cli --scan --pattern 'bull:*' |
Iterate queue keys without blocking |
redis-cli --stat |
Live stats refresh (memory, ops/sec, clients) |
RedisInsight (by Redis)
RedisInsight is a GUI for Redis featuring a key browser, memory analysis, and command profiling. It connects to local, remote, TLS, and ElastiCache instances. The PROFILER tab provides interactive SLOWLOG visualization with flame charts.
Limitation: RedisInsight is not queue-aware -- you see raw keys, not job counts or throughput metrics.
redis_exporter + Prometheus + Grafana
The redis_exporter (by Oliver Eilhard) exposes all INFO metrics as Prometheus format. Combined with Grafana, you get pre-built Redis dashboards and custom BullMQ panels.
# docker-compose snippet
services:
redis_exporter:
image: oliver006/redis_exporter:latest
environment:
- REDIS_ADDR=redis://my-redis:6379
ports:
- "9121:9121"
prometheus:
image: prom/prometheus:latest
volumes:
- ./prometheus.yml:/etc/prometheus/prometheus.yml
ports:
- "9090:9090"
grafana:
image: grafana/grafana:latest
ports:
- "3000:3000"
Queue Hub / QueueHub
QueueHub is a SaaS dashboard purpose-built for BullMQ and BeeQueue. It connects to any Redis -- local, TLS, Valkey, AWS ElastiCache, or private (via agent tunneling) -- with no agents or configuration required. It provides three integrated views:
- Redis health -- memory gauge, fragmentation, evictions, clients, ops/sec, SLOWLOG badge
- Queue performance -- job count cards, throughput chart, processing time, failure rate
- Worker health -- active workers, stalled jobs, heartbeats, per-worker job list
Key differentiator: queue-aware out of the box -- no Prometheus stack, no Grafana setup, no custom dashboards.
When to Use Which Tool
| Scenario | Best Tool |
|---|---|
| Quick one-off latency check | redis-cli --latency |
| Deep memory profiling | redis-cli --bigkeys + MEMORY USAGE |
| GUI key browsing | RedisInsight |
| Long-term metrics + alerting | redis_exporter + Prometheus + Grafana |
| Daily queue operations | Queue Hub / QueueHub |
| Production incident triage | QueueHub + redis-cli |
Best Practices and Alerting Thresholds
Eviction Policy -- No Compromise
Set maxmemory-policy noeviction. This is the single most important configuration for queue Redis instances.
# redis.conf -- production queue Redis
maxmemory 4gb
maxmemory-policy noeviction
activedefrag yes
save 900 1
save 300 10
save 60 10000
appendonly yes
appendfsync everysec
Memory Limit Sizing
- Calculate your working set:
max(completed + failed sets) + avg(queued jobs * payload size) - Add 30--50% headroom for BGSAVE copy-on-write
- Example: 2GB working set -> 3--4GB
maxmemory
Alerting Thresholds Reference
| Metric | Warning | Critical | Action |
|---|---|---|---|
| Memory usage (% of maxmemory) | > 75% | > 90% | Clean completed jobs, scale up |
mem_fragmentation_ratio |
> 1.5 | > 2.0 or < 1.0 | Enable activedefrag; restart if < 1.0 |
evicted_keys (delta/hr) |
> 0/hr | > 100/hr | Change eviction policy, add capacity |
| SLOWLOG entries (last 5 min) | > 5 | > 20 | Optimize queries, review Lua scripts |
| P99 command latency | > 50ms | > 200ms | Check SLOWLOG, split busy queues |
| Connected clients (% of max) | > 80% | > 95% | Check for connection leaks |
| Instantaneous ops/sec (spike) | 2x baseline | 5x baseline | Throttle producers, check for runaway loops |
| Failed job rate | > 5% | > 20% | Check error types, review worker code |
| Waiting + delayed jobs | Rising for 1 hr | Doubled in 1 hr | Scale workers, optimize processing |
| Stalled jobs (delta/hr) | > 1/hr | > 10/hr | Check worker health, network latency |
| Cache hit ratio (if caching) | < 0.8 | < 0.5 | Investigate key expiry patterns |
Production Lessons from the Trenches
- BGSAVE doubles RSS via copy-on-write -- always leave memory headroom
- Clean completed jobs aggressively -- default BullMQ keeps everything forever
stallIntervalmatters -- tune it to match your job duration- Monitor
omemin CLIENT LIST -- high output-buffer memory signals a slow consumer - BullMQ Lua EVALSHA appearing in SLOWLOG means heavy load -- these scripts are fast by design, so slowness indicates real pressure
- NEVER use MONITOR in production -- use
INFO commandstatsinstead - Test Redis failover -- what happens when ElastiCache fails over? Do your workers reconnect?
Conclusion -- Three Signals, One Dashboard
Redis queue health boils down to three signals:
- Latency -- measured via
redis-cli --latency,SLOWLOG, and BullMQ worker stalled events - Memory -- tracked through
INFO memory, key-type analysis, and eviction monitoring - Throughput -- exposed by BullMQ's
getJobCounts(),getMetrics(), and Prometheus export
Redis gives you the primitives (INFO, SLOWLOG, LATENCY DOCTOR). BullMQ adds queue-aware metrics (getJobCounts, getMetrics). Prometheus and Grafana provide long-term storage and alerting.
But stitching all of these together requires significant setup, configuration, and maintenance. That is where QueueHub comes in.
Try QueueHub for your BullMQ or BeeQueue queues -- full Redis queue visibility in minutes, no agents, no configuration headaches. Get started at QueueHub and see your Redis queue health on one screen: latency, memory, throughput, and worker status, all in one place.
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.