How BullMQ maps job lifecycle to Sorted Sets, Lists, and Hashes. Worker polling, delayed job scheduling, stalled job detection via heartbeat, the rate limiter internals, and choosing BullMQ vs raw Streams.
P-6 — BullMQ Internals: The Redis Data Structures Behind the Job Queue
Who this module is for: You use BullMQ (or Bull) for job queues and have run into issues — jobs that get stuck, queues that slow down under load, stalled job detection that is too aggressive or not aggressive enough. This module explains the Redis data structures BullMQ uses for every queue state, so you can reason about its behaviour, tune it correctly, and debug it at the Redis level.
Why Understanding BullMQ Internals Matters
BullMQ is a job queue built on Redis. Most engineers treat it as a black box — they add jobs with queue.add() and process them with a processor function passed straight into new Worker(queueName, processorFn). But when queues misbehave (jobs stay in "active" forever, delayed jobs fire late, rate limits fail), you cannot diagnose or fix the problem without understanding the Redis layer.
Every BullMQ behaviour maps to specific Redis operations. Knowing this lets you:
- Query queue state directly with
redis-cliwithout going through BullMQ's API - Understand why a job is "stuck" and fix it
- Tune TTL, stall checks, and rate limiter settings appropriately
- Identify Redis memory usage caused by large queues
The Key Schema
BullMQ uses a namespaced key prefix. For a queue named emails:
bull:emails:id → String: auto-incrementing job ID counter
bull:emails:wait → List: jobs waiting to be picked up (FIFO)
bull:emails:active → List: jobs currently being processed
bull:emails:completed → Sorted Set: completed jobs (score = completion timestamp)
bull:emails:failed → Sorted Set: failed jobs (score = failure timestamp)
bull:emails:delayed → Sorted Set: delayed jobs (score = run-at timestamp)
bull:emails:prioritized → Sorted Set: priority jobs (score = priority × time)
bull:emails:paused → List: queue is paused, jobs go here instead of wait
bull:emails:meta → Hash: queue metadata (paused, maxLen, etc.)
bull:emails:{jobId} → Hash: job data (id, data, opts, timestamp, etc.)
bull:emails:events → Stream: BullMQ events (completed, failed, stalled, etc.)
bull:emails:rate-limiter → String or Hash: rate limiter state
bull:emails:stalled-check:{lockKey} → key used for stall detection
Job Lifecycle in Redis
Adding a Job (queue.add)
await emailQueue.add('send-welcome', { userId: '1001', email: 'j@example.com' });
What happens in Redis:
INCR bull:emails:id→ generates job ID, e.g.,42HSET bull:emails:42with all job fields:id: "42"name: "send-welcome"data: '{"userId":"1001","email":"j@example.com"}'opts: '{"attempts":1,"delay":0,...}'timestamp: "1717000000000"delay: "0"priority: "0"
RPUSH bull:emails:wait 42→ add job ID to the wait listXADD bull:emails:events * event added jobId 42→ emit event to the events stream
The job data (step 2) is stored in a Hash for O(1) field access. The queue lists and sorted sets store only the job ID — the actual data is always in the Hash.
Adding a Delayed Job
await emailQueue.add('send-followup', { userId: '1001' }, { delay: 3600000 }); // 1 hour
Instead of RPUSH bull:emails:wait, BullMQ uses:
ZADD bull:emails:delayed {runAt_timestamp_ms} {jobId}
A scheduler process (the QueueScheduler class — a BullMQ-specific concept used before BullMQ 2.0, since merged directly into the Worker itself) polls the delayed sorted set with:
ZRANGEBYSCORE bull:emails:delayed 0 {now_ms} COUNT 100
When jobs become ready (their score ≤ current timestamp), the scheduler moves them to bull:emails:wait via LPUSH and ZREM.
Adding a Priority Job
await emailQueue.add('vip-email', { userId: '99' }, { priority: 1 }); // lower = higher priority
ZADD bull:emails:prioritized {priority_score} {jobId}
Workers preferentially consume from prioritized before wait.
Processing a Job (worker picks up)
The worker calls:
LMOVE bull:emails:wait bull:emails:active RIGHT LEFT
This atomically moves the job ID from the tail of wait to the head of active. If no jobs are waiting, the worker calls:
BLMOVE bull:emails:wait bull:emails:active RIGHT LEFT 5
Blocking for up to 5 seconds. When a job arrives, the BLMOVE completes and the job ID is in active.
The worker then reads the job data:
HGETALL bull:emails:{jobId}
Sign in to keep reading
The rest of this module is free — sign in with Google to unlock it and track your progress.
Sign in & RegisterDiscussion
0Join the discussion