·QueueHub Team·17 min read

What Does One BullMQ Job Really Cost in Redis? An Exact Memory Breakdown

RedisBullMQMemory ManagementCapacity PlanningSerializationRedis on Flash

You've configured maxmemory-policy noeviction. You've set removeOnComplete on your workers. Your queue is running smoothly — yet INFO memory still shows Redis consuming 10, 20, even 50 GB of RAM. What's eating all that memory?

Our previous posts covered the essentials — eviction policies, maxmemory configuration, and basic job retention with removeOnComplete / removeOnFail. This post goes deeper: we'll measure the exact per-job memory cost in BullMQ, profile Redis RDB dumps to find hidden memory hogs, shrink job payloads with binary serialization and compression, build a production capacity planner, and explore what happens when your queue data exceeds available RAM with tiered storage.


Section 1: The Memory Cost Per Job — How BullMQ Uses Redis Internally

Every BullMQ queue spins up a family of Redis keys. Understanding which data structures back each job state is the first step to understanding your memory bill.

BullMQ's Redis Key Anatomy

For a queue named email-queue, BullMQ creates these keys:

Redis Key Type Purpose
bull:email-queue:wait LIST Job IDs waiting to be processed
bull:email-queue:active SET Job IDs currently being processed
bull:email-queue:completed ZSET Completed job IDs (score = timestamp)
bull:email-queue:failed ZSET Failed job IDs (score = timestamp)
bull:email-queue:delayed ZSET Delayed job IDs (score = timestamp)
bull:email-queue:prioritized ZSET Job IDs with custom priority
bull:email-queue:id STRING Auto-incrementing job ID counter
bull:email-queue:meta HASH Queue metadata
bull:email-queue:{jobId} HASH Per-job data, opts, and metadata
bull:email-queue:events STREAM Real-time event notifications
bull:email-queue:repeat ZSET Repeatable job definitions
bull:email-queue:stalled-check ZSET Stall monitoring timestamps

The Hidden Cost of Container Overhead

A single job in the "waiting" state occupies:

  • 1 entry in the wait LIST — a quicklist node pointer, roughly 32 bytes
  • 1 HASH key (bull:email-queue:{jobId}) storing data, opts, name, timestamp, delay, etc.

But the real cost reveals itself at scale. Consider a completed job: it lives in the completed ZSET (a skiplist + hash table, O(log n) operations) plus its HASH key. The ZSET overhead per entry is substantial:

  • ~24 bytes per skiplist node pointer
  • ~8 bytes per element value (the job ID)
  • ~8 bytes per score (double — BullMQ stores completion timestamps)
  • ~8 bytes per back-pointer
  • ~8 bytes for the dictionary entry for O(1) lookups
  • Total ZSET overhead per entry: ~70–80 bytes

A LIST entry, by contrast, is just a quicklist node — roughly 32 bytes. That means a job in the completed set costs over twice as much in container overhead as a job sitting in wait.

Measuring Live Memory with MEMORY USAGE

You can measure this directly on your own Redis instance:

import Redis from 'ioredis';

const redis = new Redis();

interface QueueMemoryBreakdown {
  waitingBytes: number;
  activeBytes: number;
  completedBytes: number;
  failedBytes: number;
  delayedBytes: number;
  jobHashBytes: number;
  totalPerJob: number;
  numWaiting: number;
  numCompleted: number;
  numDelayed: number;
}

async function analyzeQueueMemory(
  redis: Redis,
  queueName: string
): Promise<QueueMemoryBreakdown> {
  const prefix = `bull:${queueName}`;
  const [
    waitMemory,
    activeMemory,
    completedMemory,
    failedMemory,
    delayedMemory,
    waitCount,
    completedCount,
    delayedCount,
  ] = await Promise.all([
    redis.call('MEMORY', 'USAGE', `${prefix}:wait`),
    redis.call('MEMORY', 'USAGE', `${prefix}:active`),
    redis.call('MEMORY', 'USAGE', `${prefix}:completed`),
    redis.call('MEMORY', 'USAGE', `${prefix}:failed`),
    redis.call('MEMORY', 'USAGE', `${prefix}:delayed`),
    redis.llen(`${prefix}:wait`),
    redis.zcard(`${prefix}:completed`),
    redis.zcard(`${prefix}:delayed`),
  ]);

  // Sample a single job's hash memory
  const sampleJobId = `${prefix}:${
    (await redis.lindex(`${prefix}:wait`, 0)) || '1'
  }`;
  const jobHashMemory = sampleJobId
    ? (await redis.call('MEMORY', 'USAGE', sampleJobId)) ?? 0
    : 0;

  return {
    waitingBytes: Number(waitMemory) || 0,
    activeBytes: Number(activeMemory) || 0,
    completedBytes: Number(completedMemory) || 0,
    failedBytes: Number(failedMemory) || 0,
    delayedBytes: Number(delayedMemory) || 0,
    jobHashBytes: Number(jobHashMemory) || 0,
    totalPerJob: Number(jobHashMemory) || 0,
    numWaiting: Number(waitCount),
    numCompleted: Number(completedCount),
    numDelayed: Number(delayedCount),
  };
}

Real-World Per-Job Memory Breakdown

For a 1 KB job payload at scale with 1 million jobs in the system:

Component Memory per Job At 1M Jobs
Job HASH (data + opts) ~1,200 bytes ~1.2 GB
ZSET (completed) entry ~75 bytes ~75 MB
ZSET (delayed) entry ~75 bytes ~75 MB
LIST (waiting) entry ~32 bytes ~32 MB
SET (active) entry ~52 bytes ~52 MB
Stream events buffer varies ~100 MB+
Total per job in system ~1,400+ bytes ~1.5 GB+

Key takeaway: the job HASH dominates, but ZSET overhead for completed/failed jobs grows linearly with retention time. If you retain completed jobs for 7 days at 100 jobs/second, that ZSET alone consumes ~4.5 GB at steady state.


Section 2: RDB Profiling — Finding Hidden Memory Hogs with redis-rdb-tools

The live MEMORY USAGE command is great for spot-checks, but it requires iterating over every key — expensive and impractical on production instances with millions of keys. Enter RDB profiling: copy your production RDB dump to a staging box and analyze it offline with zero impact.

Why RDB Dump Analysis?

  • No Redis CPU overhead — the dump is a static snapshot
  • Full key enumeration — every key, every byte, accounted for
  • Works on managed Redis — ElastiCache, Redis Cloud, and other services restrict MEMORY USAGE on large datasets
  • Historical comparison — keep weekly dumps to track memory growth trends

Installing redis-rdb-tools

pip install rdbtools python-lzf

Generating a BullMQ-Focused Memory Report

# Export all BullMQ keys larger than 1 KB to CSV
rdb --command memory /var/lib/redis/dump.rdb \
  --bytes 1024 \
  --key "bull:*" \
  -f bullmq-memory-report.csv

# Get the top 20 largest keys across the entire dump
rdb --command memory /var/lib/redis/dump.rdb \
  --largest 20 \
  -f top20-keys.csv

Post-Processing the CSV for Queue Insights

The CSV output has columns: database,type,key,size_in_bytes,encoding,num_elements,len_largest_element,expiry. Here's how to aggregate by BullMQ key pattern:

# Aggregate memory by BullMQ data structure type
awk -F',' 'NR>1 {
  split($3, parts, ":");
  type = parts[3];  # e.g., "completed", "wait", "1" (job hash)
  sizes[type] += $4;
  count[type]++
} END {
  for (t in sizes)
    printf "%s\t%d MB\t%d keys\n", t, sizes[t]/(1024*1024), count[t]
}' bullmq-memory-report.csv | sort -k2 -rn | head -10

This might reveal something like:

completed    2,290 MB      1,123,400 keys
delayed        823 MB        450,100 keys
1              512 MB        450,000 keys    ← job hashes! The "1" pattern
wait            122 MB        210,000 keys

Finding Oversized Jobs

Individual job hashes that store large payloads can silently consume gigabytes:

# Find job hashes (bull:*:<integer>) over 100 KB
rdb --command memory /var/lib/redis/dump.rdb \
  --key "bull:*:[0-9]*" \
  --bytes 102400 \
  -f oversized-jobs.csv

# List the worst offenders
sort -t',' -k4 -rn oversized-jobs.csv | head -10

RedisInsight Alternative

If you prefer a GUI, RedisInsight's Database Analysis feature provides the same information:

  1. Connect to your Redis instance
  2. Navigate to Database AnalysisRun Analysis
  3. Check the Top Keys tab for the largest keys
  4. Use the Key Patterns tab, filtered to bull:*, to see memory grouped by data structure
  5. Filter by type: hash for job data, zset for completed/delayed sets

Section 3: Memory-Efficient Job Serialization — MessagePack, Snappy, and Zstd

The previous sections identified the problem: job HASH data is the dominant memory consumer. Standard JSON.stringify is wasteful — field names like "data", "opts", "name", "timestamp" repeat in every job's Redis HASH. Let's shrink that.

The Serializer Abstraction

We'll define a common interface so you can swap serializers without changing queue or worker logic:

import { encode, decode } from '@msgpack/msgpack';
import { Queue, Worker, Job } from 'bullmq';
import type { ConnectionOptions } from 'bullmq';

type Serializer = {
  encode<T>(data: T): Buffer;
  decode<T>(buffer: Buffer): T;
};

const msgpackSerializer: Serializer = {
  encode: <T>(data: T): Buffer => Buffer.from(encode(data)),
  decode: <T>(buffer: Buffer): T => decode(buffer) as T,
};

Wrapping BullMQ's Queue and Worker

class CompressedQueue {
  private queue: Queue;
  private serializer: Serializer;

  constructor(
    name: string,
    opts: { connection: ConnectionOptions; serializer?: Serializer }
  ) {
    this.queue = new Queue(name, { connection: opts.connection });
    this.serializer = opts.serializer ?? msgpackSerializer;
  }

  async add<T>(
    name: string,
    data: T,
    opts?: { delay?: number; priority?: number; removeOnComplete?: boolean }
  ) {
    const encoded = this.serializer.encode(data);
    return this.queue.add(name, encoded, opts as any);
  }

  async close(): Promise<void> {
    await this.queue.close();
  }
}

class CompressedWorker {
  private worker: Worker;
  private serializer: Serializer;

  constructor(
    name: string,
    handler: (data: any) => Promise<void>,
    opts: ConnectionOptions & { serializer?: Serializer }
  ) {
    const serializer = opts.serializer ?? msgpackSerializer;
    this.serializer = serializer;

    this.worker = new Worker(
      name,
      async (job: Job) => {
        const rawBuffer = job.data as Buffer;
        const decodedData = serializer.decode(rawBuffer);
        await handler(decodedData);
      },
      { connection: opts }
    );
  }

  async close(): Promise<void> {
    await this.worker.close();
  }
}

// Usage
async function example() {
  const compressedQueue = new CompressedQueue('email', {
    connection: { host: 'localhost', port: 6379 },
  });

  await compressedQueue.add('send', {
    to: 'user@example.com',
    template: 'welcome',
    userId: 12345,
  });

  const worker = new CompressedWorker(
    'email',
    async (data) => {
      console.log('Processing:', data.to, data.template);
    },
    { host: 'localhost', port: 6379 }
  );
}

Adding Snappy Compression

For larger job payloads (10 KB+), adding compression on top of serialization yields significant savings:

import snappy from 'snappy';

const snappySerializer: Serializer = {
  encode: <T>(data: T): Buffer => {
    const json = JSON.stringify(data);
    return snappy.compressSync(json);
  },
  decode: <T>(buffer: Buffer): T => {
    const decompressed = snappy.decompressSync(buffer);
    return JSON.parse(decompressed.toString('utf-8')) as T;
  },
};

Zstd for Maximum Compression

When you need the highest ratio and have CPU headroom:

import zstd from '@napi-rs/zstd';

const zstdSerializer: Serializer = {
  encode: <T>(data: T): Buffer => {
    const json = JSON.stringify(data);
    return zstd.compressSync(Buffer.from(json), 3); // level 3 = fast
  },
  decode: <T>(buffer: Buffer): T => {
    const decompressed = zstd.decompressSync(buffer);
    return JSON.parse(decompressed.toString('utf-8')) as T;
  },
};

Compression Comparison

For a 1 KB job payload at 1 million jobs:

Format Size / Job 1M Jobs Total Encode Rate Decode Rate
JSON (baseline) 1,024 B ~1 GB
MessagePack ~650 B ~650 MB 250k ops/sec 280k ops/sec
JSON + Snappy ~350 B ~350 MB 200k ops/sec 220k ops/sec
JSON + Zstd lv3 ~200 B ~200 MB 100k ops/sec 120k ops/sec
MsgPack+Snappy ~250 B ~250 MB 180k ops/sec 200k ops/sec

Recommendation: Start with MessagePack alone — it's the best bang-for-buck with zero compression CPU cost and ~35% memory reduction. Add Snappy when job data exceeds 10 KB. Reserve Zstd for when you're hitting hard memory limits and have CPU headroom.

Caveats

  • Stored jobs become binary blobs — you can't read them from redis-cli anymore
  • Queue Hub and bull-board will show the data as base64 or raw bytes unless they support binary decoding
  • removeOnComplete / removeOnFail still work as expected
  • Both producer and consumer must use the same serializer version — consider adding a serializationVersion field to job.opts for safe rolling migrations

Section 4: Capacity Planning — From Throughput to RAM

Knowing the per-byte cost of a job is only useful if you can project it forward. Here's a complete TypeScript capacity planner that takes your queue workload and returns a concrete Redis instance recommendation.

The BullMQ Memory Formula

RAM_needed =
  (waiting_jobs   * cost_per_waiting)
+ (active_jobs    * cost_per_active)
+ (completed_jobs * cost_per_completed)
+ (delayed_jobs   * cost_per_delayed)
+ (failed_jobs    * cost_per_failed)
+ queue_overhead
+ replication_buffer
+ headroom (20–30% for RDB snapshots and bursts)

Complete TypeScript Calculator

interface QueueWorkload {
  throughputPeak: number;
  avgProcessingTime: number;
  completedRetentionSeconds: number;
  failedRetentionSeconds: number;
  avgJobDataBytes: number;
  failureRate: number;
  replicas: number;
}

interface MemoryEstimate {
  waitingMemoryGB: number;
  activeMemoryGB: number;
  completedMemoryGB: number;
  failedMemoryGB: number;
  delayedMemoryGB: number;
  jobHashMemoryGB: number;
  queueOverheadGB: number;
  totalNeededGB: number;
  withReplicationGB: number;
  withHeadroomGB: number;
  recommendedMaxmemoryGB: number;
}

// BullMQ internal memory constants (measured on Redis 7.2, 64-bit)
const OVERHEAD = {
  LIST_ENTRY: 32,
  ZSET_ENTRY: 75,
  SET_ENTRY: 52,
  KEY_OVERHEAD: 56,
  JOB_HASH_META: 256,
  FIXED_PER_QUEUE: 8192,
} as const;

function estimateBullMQMemory(workload: QueueWorkload): MemoryEstimate {
  // Steady-state active jobs = throughput * processing time (Little's Law)
  const activeJobs = Math.ceil(
    workload.throughputPeak * workload.avgProcessingTime
  );

  // Completed/failed jobs accumulate over retention window
  const completedJobs = Math.ceil(
    workload.throughputPeak *
      workload.completedRetentionSeconds *
      (1 - workload.failureRate)
  );
  const failedJobs = Math.ceil(
    workload.throughputPeak *
      workload.failedRetentionSeconds *
      workload.failureRate
  );

  // Delayed and waiting buffers
  const delayedJobs = Math.ceil(workload.throughputPeak * 30 * 0.1);
  const waitingBufferJobs = Math.ceil(workload.throughputPeak * 5);

  // Job hash = key overhead + data + BullMQ metadata fields
  const jobHashSize =
    OVERHEAD.KEY_OVERHEAD +
    workload.avgJobDataBytes +
    OVERHEAD.JOB_HASH_META;

  const totalJobsInSystem =
    activeJobs + completedJobs + failedJobs + delayedJobs + waitingBufferJobs;

  const waitingMemory = waitingBufferJobs * OVERHEAD.LIST_ENTRY;
  const activeMemory = activeJobs * OVERHEAD.SET_ENTRY;
  const completedMemory = completedJobs * OVERHEAD.ZSET_ENTRY;
  const failedMemory = failedJobs * OVERHEAD.ZSET_ENTRY;
  const delayedMemory = delayedJobs * OVERHEAD.ZSET_ENTRY;
  const jobHashMemory = totalJobsInSystem * jobHashSize;
  const queueOverhead = OVERHEAD.FIXED_PER_QUEUE;

  const totalBytes =
    waitingMemory +
    activeMemory +
    completedMemory +
    failedMemory +
    delayedMemory +
    jobHashMemory +
    queueOverhead;

  const totalGB = totalBytes / (1024 * 1024 * 1024);
  const withReplicationGB = totalGB * (1 + workload.replicas);
  const withHeadroomGB = withReplicationGB * 1.3;

  return {
    waitingMemoryGB: +(waitingMemory / (1024 * 1024 * 1024)).toFixed(3),
    activeMemoryGB: +(activeMemory / (1024 * 1024 * 1024)).toFixed(3),
    completedMemoryGB: +(completedMemory / (1024 * 1024 * 1024)).toFixed(3),
    failedMemoryGB: +(failedMemory / (1024 * 1024 * 1024)).toFixed(3),
    delayedMemoryGB: +(delayedMemory / (1024 * 1024 * 1024)).toFixed(3),
    jobHashMemoryGB: +(jobHashMemory / (1024 * 1024 * 1024)).toFixed(3),
    queueOverheadGB: +(queueOverhead / (1024 * 1024 * 1024)).toFixed(4),
    totalNeededGB: +totalGB.toFixed(2),
    withReplicationGB: +withReplicationGB.toFixed(2),
    withHeadroomGB: +withHeadroomGB.toFixed(2),
    recommendedMaxmemoryGB: Math.ceil(withHeadroomGB),
  };
}

// Example: 100 jobs/sec, 2s processing time, 3-day completion retention
const estimate = estimateBullMQMemory({
  throughputPeak: 100,
  avgProcessingTime: 2,
  completedRetentionSeconds: 86400 * 3,
  failedRetentionSeconds: 86400 * 7,
  avgJobDataBytes: 2 * 1024,
  failureRate: 0.05,
  replicas: 1,
});

console.log('=== BullMQ Memory Capacity Plan ===');
console.log('Total (no replication):', estimate.totalNeededGB, 'GB');
console.log('With replication:', estimate.withReplicationGB, 'GB');
console.log('With 30% headroom:', estimate.withHeadroomGB, 'GB');
console.log('Recommended maxmemory:', estimate.recommendedMaxmemoryGB, 'GB');

Common Workload Scenarios

Throughput Retention Job Size Calculated RAM Recommended Instance
50/s 1 day 1 KB 1.2 GB r6g.large (1.6 GB)
200/s 3 days 2 KB 15.6 GB r6g.xlarge (32 GB)
1000/s 7 days 5 KB 214 GB r6g.4xlarge (256 GB) or cluster
5000/s 1 day 500 B ~150 GB r6gd.4xlarge with data tiering

Sensitivity Analysis — What Matters Most

  1. Job data size — linear with job count, the dominant term. A 10 KB payload costs 10x more than a 1 KB payload. This is where serialization from Section 3 pays off.
  2. Completed retention — ZSET entries * retention days. This is the second-biggest term and the easiest to control.
  3. Throughput — everything scales linearly with throughput.
  4. Failure rate — failed jobs with long retention are silent memory landmines. If 10% of jobs fail and you keep them for 30 days, that's 3x the completed memory for that subset.
  5. Delayed jobs — ZSET entries with timestamp-ordered scores. Deep delay queues are expensive.

Section 5: Redis on Flash / Tiered Storage — When Your Queue Doesn't Fit in RAM

Everything above assumes all queue data lives in RAM. But what if your workload legitimately needs to retain millions of completed jobs and pure RAM costs are prohibitive?

The Fundamental Tension

BullMQ's internal design assumes all keys are in RAM. Operations like moveToActive, moveToCompleted, and moveToFailed are atomic Lua scripts running at microsecond speeds. If data is on flash, those operations risk 300 µs+ latency penalties on cold reads.

However, two tiered storage options let you balance cost and performance.

Redis Enterprise Auto Tiering

Redis Auto Tiering (formerly Redis on Flash) stores keys in RAM and values on SSD. This is transparent to Redis commands — your BullMQ instance works unmodified.

  • Hot data is promoted to RAM on access
  • Warm data is demoted to flash when memory fills (LRU-like)
  • Hardware requirement: NVMe SSDs (recommended on AWS i4i instances)

Impact on BullMQ: Container keys (wait LIST, active SET, completed ZSET) are always in RAM because they're keys. The actual job payloads in bull:queue:{jobId} HASH values migrate to flash. Since most operations that access job data (completion, failure, retry) touch the most recently processed jobs, the working set stays naturally hot.

ElastiCache Data Tiering (r6gd Instances)

Same concept: keys stay in RAM, values live on SSD using an LRU algorithm.

  • Maximum value size for SSD: 128 MiB (larger values stay in RAM)
  • Valkey 8.1+: items under 40 bytes always stay in RAM
  • Cost savings: ~60% vs pure memory at full utilization

BullMQ Compatibility Assessment

interface TieredStorageConfig {
  workingSetRatio: number;
  avgJobDataBytes: number;
  latencySensitivity: 'low' | 'medium' | 'high';
}

function evaluateTieredStorageFit(
  config: TieredStorageConfig
): 'excellent' | 'good' | 'caution' | 'avoid' {
  const isWorkingSetSmall = config.workingSetRatio <= 0.2;
  const isValueLarge = config.avgJobDataBytes > 512;
  const isLatencyTolerant =
    config.latencySensitivity === 'low' ||
    config.latencySensitivity === 'medium';

  if (isWorkingSetSmall && isValueLarge && isLatencyTolerant) {
    return 'excellent';
  }
  if (isWorkingSetSmall && isValueLarge) {
    return 'good';
  }
  if (isWorkingSetSmall || isLatencyTolerant) {
    return 'caution';
  }
  return 'avoid';
}

BullMQ Patterns to Avoid with Tiered Storage

  • Flow Producer / parent-child DAGs: getChildren scans many job hashes in a tight loop — each could trigger a flash promotion
  • High-throughput delayed jobs: moveToDelayed promotes on every cycle; deep delay queues trigger mass promotions
  • Repeatable jobs with large payloads: the repeat ZSET entry is tiny, but the job HASH for the repeat definition will live on flash

Hybrid Recommendation

Use tiered storage primarily for completed and failed job retention, while keeping waiting/active data in RAM:

  • Set removeOnComplete: { age: 86400 * 7 } to cap completed retention at 7 days
  • Let tiered storage handle value promotion/demotion for cold completed/failed jobs
  • Monitor CurrItems(Tier=Memory) vs CurrItems(Tier=Flash) in CloudWatch (ElastiCache) or auto_tiering_ram_percentage (Redis Enterprise)
  • If the working set ratio drops below 10%, consider lowering maxmemory to force more data to flash

Deployment Comparison

Approach Cost / GB Max Size Latency (cold) BullMQ Compatibility
Pure RAM (r6g) ~$5/GB 512 GB 50 µs Perfect
ElastiCache Data Tiering ~$2/GB 1 PB* ~300 µs Good (warm working set)
Redis Enterprise Flash ~$1/GB 100s TB ~500 µs Good (tested by Redis)
Disk-based (SQLite/PG) ~$0.05/GB Unlimited 1–10 ms Wrong tool for queues

ElastiCache r6gd.16xlarge, 500-node cluster max


Putting It All Together

We've covered five practical angles for understanding and controlling BullMQ's Redis memory:

  1. Measure the true per-job cost — BullMQ's data structures add significant overhead beyond just your job payload
  2. Profile with RDB dumps — find hidden memory hogs without impacting production
  3. Shrink job payloads — MessagePack, Snappy, and Zstd wrappers reduce memory 35–80%
  4. Plan capacity mathematically — the TypeScript calculator turns throughput and retention into concrete instance specs
  5. Consider tiered storage — Redis on Flash can cut costs 60% when your working set is small

The consistent thread: measure before you optimize. One team's "unexplained 10 GB Redis memory" might be a 7-day retention window for 500 KB job payloads; another's might be an events stream buffer growing unbounded. Run the analyzeQueueMemory() function, parse a dump, check your retention window — and only then reach for serialization or tiered storage.

Ready to see your BullMQ memory usage at a glance? Queue Hub connects directly to your Redis and surfaces every metric we've discussed — job count per status, memory by key pattern, and capacity alerts — without running a single RDB parse. Try it free at queuehub.tech.

Related Articles