·QueueHub Team·15 min read

Redis Memory Profiling & Capacity Planning for BullMQ Queues

RedisBullMQMemory ManagementCapacity PlanningPerformanceProfiling

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?

  1. Each job costs roughly its payload size × 4.5. A 1 KB job → ~4.5 KB real cost.
  2. Each queue's event stream grows at ~200 bytes × 5–10 events × throughput. At 50 jobs/s, that's 50–100 MB/day.
  3. For every 1,000 jobs/s throughput, budget at least 8–12 GB for active jobs plus short-term (1 hour) retention.
  4. 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