Redis Memory Profiling & Capacity Planning for BullMQ Queues
Move beyond maxmemory guesswork — learn to measure, calculate, and plan your Redis memory with precision using profiling tools, serialization optimizations, and capacity formulas.
A team runs BullMQ in production. Their delayed job queue hits 4.5 million jobs. Redis memory usage climbs to 10 GB — and keeps climbing. They're planning to scale to 20 million jobs, but nobody can tell them whether they need a 32 GB instance or a 128 GB instance. Setting maxmemory higher isn't the answer. Understanding where every byte goes is.
That GitHub issue (#2734 on bullmq) is real. And it's far from unique. Most BullMQ teams treat Redis memory as a vague cost center — "set maxmemory, hope it's enough, add RAM when the pager goes off." This post changes that.
You'll learn how to calculate the exact memory cost per job, profile your Redis instance to find exactly which queues are consuming RAM, shrink job payloads by 2–7× through better serialization, and answer the question "what Redis instance do I actually need?" with a real formula.
The Anatomy of a BullMQ Job's Memory Footprint
When you add a job to a BullMQ queue, you're not storing one thing — you're storing entries across multiple Redis data structures, each with its own overhead. The job payload is just the beginning.
Beyond the Payload — Redis Data Structure Overhead
Every job creates at least one key in Redis: a hash stored at bull:queue:{jobId}. That hash alone carries ~13 fields (data, opts, name, timestamp, delay, attemptsMade, returnvalue, finishedOn, processedOn, stacktrace, failedReason, progress) plus your actual job data. But that's only the per-job key.
The queue itself maintains shared data structures that also reference the job:
| Structure | Key Pattern | Memory per Entry | Job State |
|---|---|---|---|
| List (quicklist) | bull:queue:wait |
~42 bytes | Waiting, Active |
| Sorted Set (skiplist) | bull:queue:completed |
~110 bytes | Completed, Failed, Delayed, Prioritized |
| Stream | bull:queue:events |
~84+ bytes | Lifecycle events |
| Hash | bull:queue:{jobId} |
~96 + payload + 512 metadata | All states |
Sorted sets are the biggest hidden cost. Each entry in a sorted set requires a dictionary entry plus a skiplist node (forward and backward pointers, score, element). That's roughly 2.5–3× the cost of a list entry. If you're keeping 500,000 completed jobs with a 1 KB payload, the sorted set entries alone add ~55 MB of overhead beyond the job hashes themselves.
The event stream (bull:queue:events) is another commonly overlooked memory hog. Every job lifecycle transition (added → waiting → active → completed/failed) appends an entry. At ~200 bytes per event with 5–10 transitions per job, a queue processing 50 jobs/s accumulates hundreds of megabytes per day in event entries alone.
The Job Memory Calculator
Rather than guessing, here's a practical utility that estimates the true Redis cost per job, accounting for all data structure overhead:
interface QueueConfig {
queueName: string;
prefix: string;
jobPayloadSizeBytes: number; // JSON.stringify(job.data).length
jobIdLength: number; // e.g., 36 for UUIDv4
}
interface JobCostBreakdown {
jobHash: number;
listEntry: number;
sortedSetEntry: number;
streamEntry: number;
totalPerState: number;
realisticTotal: { min: number; max: number };
}
const REDIS_OVERHEAD = {
BASE_KEY_OVERHEAD: 64,
HASH_BASE_OVERHEAD: 96,
HASH_FIELD_OVERHEAD: 8,
BULLMQ_HASH_FIELDS: 13,
LIST_ENTRY_OVERHEAD: 42,
SORTED_SET_ENTRY_OVERHEAD: 110,
STREAM_ENTRY_BASE: 60,
STREAM_FIELD_OVERHEAD: 24,
FRAGMENTATION_RATIO: { min: 1.05, max: 1.20 },
} as const;
function keyStorageCost(keyLength: number): number {
return REDIS_OVERHEAD.BASE_KEY_OVERHEAD + keyLength;
}
function calculateJobCost(config: QueueConfig): JobCostBreakdown {
const { prefix, queueName, jobPayloadSizeBytes, jobIdLength } = config;
const keyName = `${prefix}:${queueName}:${"x".repeat(jobIdLength)}`;
// Cost of the job hash: key + hash overhead + fields + payload + metadata
const hashCost =
keyStorageCost(keyName.length) +
REDIS_OVERHEAD.HASH_BASE_OVERHEAD +
REDIS_OVERHEAD.HASH_FIELD_OVERHEAD * REDIS_OVERHEAD.BULLMQ_HASH_FIELDS +
jobPayloadSizeBytes +
512; // BullMQ metadata fields
// List entry (wait, active)
const listEntryCost = keyStorageCost(keyName.length) + REDIS_OVERHEAD.LIST_ENTRY_OVERHEAD;
// Sorted set entry (completed, failed, delayed, prioritized)
const sortedSetEntryCost = keyStorageCost(keyName.length) + REDIS_OVERHEAD.SORTED_SET_ENTRY_OVERHEAD;
// Stream entry (events)
const streamEntryCost = REDIS_OVERHEAD.STREAM_ENTRY_BASE + REDIS_OVERHEAD.STREAM_FIELD_OVERHEAD;
// Job in "waiting" state: hash + 1 list entry
const totalPerState = hashCost + listEntryCost;
const realisticMin = Math.round(totalPerState * REDIS_OVERHEAD.FRAGMENTATION_RATIO.min);
const realisticMax = Math.round(totalPerState * REDIS_OVERHEAD.FRAGMENTATION_RATIO.max);
return {
jobHash: hashCost,
listEntry: listEntryCost,
sortedSetEntry: sortedSetEntryCost,
streamEntry: streamEntryCost,
totalPerState,
realisticTotal: { min: realisticMin, max: realisticMax },
};
}
Real-World Results from the Calculator
Let's run three scenarios:
Example A — High-throughput notification queue: 50 jobs/s, 200 ms processing, 24-hour retention, 2% failure rate, 512-byte payload. The calculator estimates ~2.3 KB per job, resulting in ~10–15 GB total. That matches real-world reports from BullMQ users running notification pipelines at this scale.
Example B — Large payload data processing: 5 jobs/s, 30-second processing, 72-hour retention, 10% failure rate, 50 KB payload. Per-job cost jumps to ~53 KB — dominated by the payload itself. Total: ~65–85 GB needed. Here, the sorted set overhead is negligible compared to the job data.
Example C — The delayed job flood (matching issue #2734): 4.5 million delayed jobs at ~2.3 KB each ≈ 10.35 GB. That lines up almost exactly with the 10 GB the reporter experienced. The calculator predicts that scaling to 20 million jobs would require ~46 GB — a number the team could have known upfront with proper modeling.
Validating Your Estimates on a Live Redis
After running the calculator, validate against real Redis metrics:
# Check actual memory use of a single job hash
redis-cli MEMORY USAGE bull:myqueue:real-job-id-123
# Get encoding and serialized length info
redis-cli DEBUG OBJECT bull:myqueue:real-job-id-123
# Check total memory and fragmentation
redis-cli INFO MEMORY | grep -E "used_memory|mem_fragmentation_ratio"
# See how many jobs are in each state
redis-cli LLEN bull:myqueue:wait
redis-cli ZCARD bull:myqueue:completed
redis-cli ZCARD bull:myqueue:failed
redis-cli XLEN bull:myqueue:events
Compare the MEMORY USAGE output (actual) against the calculator estimate (expected). If they're consistently 20% off, adjust the overhead constants for your Redis version and allocator.
Redis Memory Profiling — From RDB Files to Queue-Level Insights
Guessing isn't planning. To know exactly what's consuming Redis memory, you need profiling tools that reveal per-key, per-type, and per-queue breakdowns.
redis-rdb-tools — The Gold Standard for Offline Analysis
The most accurate picture of your Redis memory comes from an RDB dump. The rdbtools package parses dump.rdb into a CSV with database, key, type, size_in_bytes, encoding, and element counts.
# Install
pip install rdbtools python-lzf
# Trigger a snapshot on your BullMQ Redis
redis-cli BGSAVE
# Wait: check LASTSAVE to confirm completion
# Generate the memory report, filtered to BullMQ keys
rdb -c memory /var/lib/redis/dump.rdb \
--key "bull:*" \
-f bullmq-memory-report.csv
# Find your top 20 memory consumers
sort -t, -k3 -rn bullmq-memory-report.csv | head -20
# See what your sorted sets are consuming
grep "sortedset" bullmq-memory-report.csv | sort -t, -k3 -rn
The real power is aggregation by queue. The key column contains the queue name as a prefix, so you can group by it:
# Aggregate memory by queue name
awk -F, 'NR>1 {
split($3, parts, ":");
queue = parts[1] ":" parts[2];
if (queue ~ /^bull:.+/) {
bytes[queue] += $4;
count[queue]++;
}
} END {
for (q in bytes)
printf "%s\t%d bytes\t%d keys\n", q, bytes[q], count[q]
}' bullmq-memory-report.csv | sort -k2 -rn
This tells you exactly which queue is consuming the most memory and how many keys it owns. If one queue's bull:myqueue:completed sorted set has 2 million elements while another has 10,000, you know where to focus your retention policy.
Live Profiling Without an RDB Snapshot
Can't take an RDB dump in production? Redis has built-in scanning tools:
# Scan for your largest keys by data type
redis-cli --bigkeys
# More accurate: scan by actual memory usage (Redis 7+)
redis-cli --memkeys
# BullMQ-specific: scan ALL queue keys and sort by memory
redis-cli --scan --pattern "bull:*" | \
while read key; do
mem=$(redis-cli MEMORY USAGE "$key")
echo "$mem $key"
done | sort -rn | head -30
The --memkeys option uses MEMORY USAGE (which considers actual allocation including overhead) instead of STRLEN (which only measures string length). It's slower — expect 1–5 seconds per million keys — but the accuracy is essential for capacity planning.
Profiling a Multi-Queue Redis Instance
When multiple BullMQ queues share a single Redis instance (common in monolith or shared-infra setups), memory attribution is critical. One queue with unbounded retention can crowd out others.
The RDB approach with queue-name aggregation (above) solves this for offline analysis. For live monitoring, tools like Queue Hub automatically track per-queue key counts, sorted set sizes, and event stream growth over time — giving you per-queue memory trends updated in real time.
Memory-Efficient Job Serialization
The fastest way to free Redis memory is to make each job smaller. Serialization format choices can shrink payloads by 30–84% without changing a single line of business logic.
The JSON Tax
JSON is verbose by design. Every key needs quotation marks. Every value needs delimiters. Every number is stored as ASCII text. Consider a simple job payload:
{
"userId": "550e8400-e29b-41d4-a716-446655440000",
"action": "send_email",
"priority": 3
}
That UUID is 36 bytes of text for 128 bits (16 bytes) of data — a 2.25× overhead. The number 3 costs 1 byte in binary but 13 bytes as ASCII. All those quotation marks, colons, and commas are pure waste.
MessagePack — The Drop-In Binary Upgrade
MessagePack is a binary serialization format that maps directly to JSON types. It eliminates the text overhead while maintaining the same data model:
import { encode, decode } from "@msgpack/msgpack";
export const MsgPackSerializer = {
serialize<T>(data: T): Buffer {
return Buffer.from(encode(data));
},
deserialize<T>(buffer: Buffer): T {
return decode(buffer) as T;
},
};
// Producer usage
import { Queue } from "bullmq";
const queue = new Queue("notifications", { connection });
await queue.add(
"sendEmail",
MsgPackSerializer.serialize({
userId: "550e8400-e29b-41d4-a716-446655440000",
action: "send_email",
priority: 3,
metadata: {
template: "welcome",
locale: "en-US",
},
})
);
// Worker usage
import { Worker } from "bullmq";
const worker = new Worker(
"notifications",
async (job) => {
const data = MsgPackSerializer.deserialize(job.data);
console.log(`Sending ${data.action} to ${data.userId}`);
},
{ connection }
);
For typical job payloads (objects with strings, numbers, arrays, nested objects), MessagePack saves ~30% compared to JSON. The savings come from:
- Binary integers:
priority: 3→ 1 byte instead of 13 - No delimiters: No colons, commas, or quotation marks between fields
- Length-prefixed strings: Avoids escape overhead for special characters
Adding Compression for Large Payloads
For payloads exceeding 2–5 KB, adding compression on top of binary serialization yields even bigger savings. Snappy (Google) offers a great balance: ~250 MB/s compression, ~500 MB/s decompression, and minimal CPU overhead.
import { compress, uncompress } from "snappy";
import { encode, decode } from "@msgpack/msgpack";
export const CompressedSerializer = {
async serialize<T>(data: T): Promise<Buffer> {
const msgpacked = encode(data);
const compressed = await compress(Buffer.from(msgpacked));
return Buffer.from(compressed);
},
async deserialize<T>(buffer: Buffer): Promise<T> {
const decompressed = await uncompress(buffer);
return decode(decompressed) as T;
},
};
A smart approach is to select serialization based on payload size:
async function addJobWithBestSerializer<T>(
queue: Queue,
jobName: string,
data: T,
opts?: object
) {
const jsonSize = new TextEncoder().encode(JSON.stringify(data)).length;
if (jsonSize > 10_000) {
return queue.add(jobName, await CompressedSerializer.serialize(data), opts);
} else if (jsonSize > 1_000) {
return queue.add(jobName, MsgPackSerializer.serialize(data), opts);
}
return queue.add(jobName, data, opts); // Small payloads: JSON is fastest
}
Real-World Savings Comparison
| Payload Type | JSON | MessagePack | MsgPack + Snappy | MsgPack + Zstd |
|---|---|---|---|---|
| Small (512 B, notification) | 512 B | 412 B (-20%) | 328 B (-36%) | 295 B (-42%) |
| Medium (10 KB, report) | 10 KB | 6.8 KB (-32%) | 3.1 KB (-69%) | 2.4 KB (-76%) |
| Large (50 KB, ETL data) | 50 KB | 34 KB (-32%) | 12 KB (-76%) | 8 KB (-84%) |
For payloads under 1 KB, stick with plain JSON — V8 parses it faster than any deserialization library, and the absolute memory savings are negligible. For payloads over 10 KB, MessagePack + Snappy routinely delivers 70%+ memory savings with under 10 microseconds of CPU overhead per job.
There's also a hidden benefit: smaller keys within Redis listpack-encoded hashes. Redis automatically uses a compact listpack encoding for hashes where all field values are under 64 bytes (hash-max-listpack-value). By shrinking your payloads, you help more job hashes qualify for this encoding — a 10× memory multiplier compared to hash-table encoding.
Capacity Planning for BullMQ at Scale
With per-job costs understood and profiling tools in hand, you can answer the real question: "How much Redis do I need?"
The Capacity Planning Formula
Total Memory =
(ActiveJobs × BaseCost) +
(RetainedCompleted × CompletedCost) +
(RetainedFailed × FailedCost) +
(DelayedJobs × DelayedCost) +
QueueOverhead +
FragmentationBuffer
Where:
- ActiveJobs =
jobsPerSecond × avgProcessingMs / 1000(Little's Law) - RetainedCompleted =
jobsPerSecond × retentionHours × 3600 × (1 - failureRate) - RetainedFailed =
jobsPerSecond × retentionHours × 3600 × failureRate - BaseCost = job hash + 1 list entry
- Completed/Failed/DelayedCost = job hash + 1 sorted set entry
- QueueOverhead = ~5–10 MB per queue (event stream, meta keys, repeatable jobs)
- FragmentationBuffer = 15–20%
Quick Heuristics
Need a rough estimate without the full formula?
- Each job costs roughly its payload size × 4.5. A 1 KB job → ~4.5 KB real cost.
- Each queue's event stream grows at ~200 bytes × 5–10 events × throughput. At 50 jobs/s, that's 50–100 MB/day.
- For every 1,000 jobs/s throughput, budget at least 8–12 GB for active jobs plus short-term (1 hour) retention.
- Sorted sets are ~3× more expensive than lists. Minimize completed/failed/delayed retention in Redis.
Scaling Strategies When You Hit the Ceiling
| Strategy | Memory Savings | Effort | Best For |
|---|---|---|---|
| Move historical jobs to DB archive | 70–90% | Medium | Long-retention queues |
| Compress payloads (Section 3) | 30–80% | Low | Any large-payload queue |
| Increase cleanup frequency | Varies | Low | High-throughput queues |
| Data tiering (Redis on Flash) | 60–80% cost reduction | None | Retention-heavy workloads |
| Split into sharded sub-queues | Indirect | High | Multi-tenant systems |
Data Tiering Options
Redis Enterprise Auto Tiering and AWS ElastiCache Data Tiering (r6gd instances) transparently move cold data — old sorted set entries for completed jobs — to SSD while keeping hot data in RAM. For BullMQ workloads that retain large completed/failed sorted sets for debugging, this is transformative.
On AWS (us-east-1, 2025 pricing):
- r6g.large (16 GB RAM, no tiering): ~$0.252/hr
- r6gd.large (16 GB RAM + 237 GB SSD): ~$0.302/hr
That's 15× more effective capacity for only 20% more cost. Recently completed jobs stay hot in RAM. Jobs completed days ago get paged to SSD transparently with only ~1–5 ms additional latency when queried by getJob().
Redis 8.x Memory Optimizations
Redis 8.2 (August 2025) introduced a new kvobj architecture that reduces memory for short string keys by 25–37%. This directly benefits BullMQ because every job hash key (bull:queue:{jobId}) is a string key pattern that benefits from the optimization. JSON numeric values also shrink by 25–67%. If you're running self-hosted Redis OSS, upgrading to 8.2 is a no-brainer for BullMQ — memory improvements, faster Lua execution, and no breaking changes.
Using Queue Hub for Proactive Capacity Planning
Queue Hub tracks used_memory, used_memory_rss, and mem_fragmentation_ratio over time, aligned with individual queue throughput. You can:
- Correlate memory growth with queue events — see exactly which queue's completed/failed sorted set is growing fastest
- Set per-queue memory growth alerts — e.g., "warn if queue X grows > 500 MB/hour"
- Get capacity forecasts — linear regression on memory vs. job count tells you "at current rate, you'll run out of memory in 14 days"
- View per-queue memory dashboards — real-time key counts by state, payload size distribution, and estimated memory consumption
Export your queue's actual throughput and retention numbers from Queue Hub, plug them into the capacity calculator, and get a sizing recommendation for your next Redis instance — before the pager goes off.
Quick Reference
Redis Memory Profiling Commands
# Live memory info
redis-cli INFO MEMORY
# Per-key memory (most accurate)
redis-cli MEMORY USAGE bull:myqueue:job-123-abc
# Big key scans
redis-cli --bigkeys # by string length
redis-cli --memkeys # by actual memory (slower, better)
# Queue-level state counts
redis-cli LLEN bull:myqueue:wait
redis-cli ZCARD bull:myqueue:completed
redis-cli ZCARD bull:myqueue:failed
redis-cli XLEN bull:myqueue:events
# RDB memory report (offline — gold standard)
rdb -c memory /var/lib/redis/dump.rdb --key "bull:*" -f report.csv
Payload Size Decision Matrix
| Payload Size | Best Serialization | Compression | Savings vs JSON |
|---|---|---|---|
| < 1 KB | JSON | None | V8 fastest; overhead irrelevant |
| 1–10 KB | MessagePack | Optional (Snappy) | ~32% |
| 10–100 KB | MessagePack | Snappy | 70%+ |
| > 100 KB | MessagePack | Zstd (level 3) | 80%+ |
Capacity Planning Quick Table
| Throughput | Payload | Retention | Recommended Redis |
|---|---|---|---|
| 10 jobs/s | 512 B | 24 h | 2 GB (r6g.small) |
| 50 jobs/s | 2 KB | 24 h | 8 GB (r6g.large) |
| 200 jobs/s | 5 KB | 72 h | 32 GB (r6g.xlarge) |
| 1,000 jobs/s | 10 KB | 168 h | 128 GB or r6gd with tiering |
| 5,000 jobs/s | 1 KB | 24 h | 64 GB + data tiering |
Redis memory management doesn't have to be guesswork. By calculating per-job costs, profiling with RDB tools, optimizing serialization, and applying a capacity formula, you can predict your Redis needs before you hit the ceiling.
Queue Hub is purpose-built for managing BullMQ and BeeQueue at scale. Track per-queue memory, set proactive capacity alerts, and visualize growth trends — all without writing a single Redis command. Try it free today.
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.