Migrating production infrastructure? Get up to ₹10,000 in migration credits. Apply now
voroaBlog
Key Value

BullMQ Guide: Redis Job Queues in Node.js, Measured

· · 10 min read

A Redis job queue in Node.js, measured: commands per job, idle worker cost and memory per job

BullMQ is one of the most widely used job queues for Node.js: you push work onto a queue in Redis, and a separate worker process picks it up and runs it. This guide is for developers adding background jobs to a Node.js app, such as sending email, resizing images or calling a slow API. Every tutorial shows the same ten lines of code. This one also shows what the queue costs your Redis, because we measured it: how many commands each job uses, how many an idle worker sends, and how much memory finished jobs keep. Those numbers decide which Redis you can run it on.

On this page

Advertisement

When a Node.js app needs a job queue

A web request should answer in well under a second. Some work takes longer than that or can fail and need a retry: sending an email through a provider, generating a PDF invoice, resizing an upload, calling a payment or AI API, or sending a webhook to a customer. Doing that inside the request makes the user wait and loses the work if the server restarts.

A queue splits it in two. The web server adds a small job record to Redis and answers at once. A worker process takes jobs off the queue, runs them, retries failures with a delay, and records the result. If the worker restarts, unfinished jobs are still in Redis.

Diagram: the web service adds a job to a queue in Redis and answers the user at once; a background worker takes the job, runs it, retries on failure and records the result

A working queue in two files

Install the package (it brings its own Redis client, ioredis):

bash
npm install bullmq

The shared setup, one file both processes import:

js
// queue.js
import { Queue, Worker } from 'bullmq';
import IORedis from 'ioredis';

// Web side: fail fast if Redis is down, so a request never hangs waiting for it.
// Trade-off: an add() before the first connect completes throws instead of waiting.
const queueConnection = new IORedis(process.env.REDIS_URL, { enableOfflineQueue: false });
export const emailQueue = new Queue('email', { connection: queueConnection });

// Worker side: keep retrying through a Redis restart instead of crashing
export const workerConnection = () =>
  new IORedis(process.env.REDIS_URL, { maxRetriesPerRequest: null });
export { Worker };

The producer, inside a route handler in your web app. It adds a job and returns:

js
import { emailQueue } from './queue.js';

await emailQueue.add('welcome', { userId: 42 }, {
  attempts: 5,
  backoff: { type: 'exponential', delay: 2000 },
  removeOnComplete: { count: 1000 },
  removeOnFail: { age: 7 * 24 * 3600 },
});

The worker, a separate long-running process:

js
// worker.js
import { Worker, workerConnection } from './queue.js';

const worker = new Worker('email', async (job) => {
  await sendWelcomeEmail(job.data.userId);   // your code
}, { connection: workerConnection(), concurrency: 10 });

worker.on('failed', (job, err) => console.error(job?.id, err.message));

process.on('SIGTERM', async () => { await worker.close(); process.exit(0); });

That is the whole pattern. The options in the producer are the ones that matter in production, and the sections below explain why.

What each job costs in Redis, measured

We ran version 6.7.1 of the library from Node.js 24 on a Windows laptop against Redis 8.10.3 in a Linux virtual machine (WSL2) on the same laptop, and counted every command the server executed, using Redis’s own command statistics and MONITOR. Measured 7 November 2026.

What we measured Result
Commands per job (add, process, complete), with removeOnComplete: true 32 (33 with defaults)
…of which your app sends over the network 2
…of which run inside the library’s Lua scripts 30
Most common commands XADD (4 per job, the event stream), HMGET, HGET, HMSET, ZADD

Two round trips per job is efficient: the library does its bookkeeping inside Lua scripts on the server, so a job costs your app very little network time.

The 32 is what matters if your Redis host charges per command. Upstash, for example, bills a script as one command plus one for every Redis call the script makes. At about 32 commands per job, the 500,000 commands in Upstash’s free tier cover roughly 15,600 jobs a month, before counting the idle cost below.

The idle worker that is never idle

A worker with nothing to do still talks to Redis. It waits for new jobs with a blocking command that times out every few seconds, and it checks for stalled jobs on a timer.

We started one worker on an empty queue and counted for two minutes:

Idle worker, empty queue Per minute Per 30-day month
Commands your app sends 26 about 1.1 million
Commands the server executes, including inside scripts about 110 about 4.8 million

So one idle worker uses nearly ten times a 500,000-command free allowance every month, without processing a single job. Two workers, double it. This is not a bug; it is how a responsive queue works. It is the reason Upstash’s own documentation recommends a fixed-price plan for worker-heavy setups, and the reason we said in our free Redis hosting comparison that a command-metered tier is the wrong fit for a queue.

The fix is to choose Redis that limits memory, not commands.

Finished jobs fill your memory

By default the library keeps every completed job in Redis, forever. We measured what that costs:

Setting Memory per job after completion
Default (keep completed jobs) about 469 bytes
removeOnComplete: true about 48 bytes

469 bytes sounds small. At 10,000 jobs a day it is about 140 MB a month, which is more than a 128 MB store holds. Your queue then stops accepting jobs, or, worse, if the store is set to evict keys, it starts deleting queue data.

Keep a bounded history instead. removeOnComplete: { count: 1000 } keeps the last thousand for your dashboard, and removeOnFail: { age: 604800 } keeps failures for a week so you can inspect and retry them.

Concurrency: how many jobs at once

concurrency is the number of jobs one worker runs at the same time. We timed 10,000 jobs that did no work, with Redis in a virtual machine on the same laptop, so this measures the queue itself:

Concurrency Jobs per second
1 about 1,950
10 about 5,870
50 about 5,830

Going from 1 to 10 tripled throughput. It then levelled off at about 5,850 a second, because with jobs that do no work, Redis, the library’s scripts and the client become the limit, not the worker. Real jobs spend most of their time waiting on an email provider or an API, so for I/O-bound work a concurrency of 10 to 50 per worker is normal. For CPU-heavy work such as image processing, keep concurrency near the number of CPU cores and add worker processes instead.

Distance changes these numbers. With concurrency 1, each job needs at least one round trip to Redis. From a server in Mumbai we measured about 9 ms to a Mumbai region and about 86 ms to Singapore, so a single-concurrency worker talking to a Singapore store tops out at roughly a dozen jobs a second, however fast your code is. That is an estimate from the round trip, not a measured queue run, but the direction is certain: keep the worker and the store in the same region.

Choosing a Redis for your queue

A queue has stricter needs than a cache:

  1. No eviction. The library cannot work if Redis deletes keys to free memory. The eviction policy must be noeviction, so a full store refuses writes rather than silently dropping jobs. See the Redis eviction policies for what each one does.
  2. Data kept on disk. A store that empties on restart loses every queued job. Use one with persistence.
  3. Memory, not commands, as the limit. As measured above, idle workers alone send millions of commands a month.
  4. Enough connections. Each Queue and Worker holds its own connection, and a worker uses an extra one for its blocking wait. Two web instances and two workers need about 6, more if you add event listeners or a dashboard.
  5. The same region as the worker. Every job pays round trips to Redis.

Valkey and Redis should both work for this, since the queue uses core commands and Lua scripts; test with your own jobs before you rely on Valkey. Our Valkey vs Redis comparison covers the differences that do exist.

On Voroa, a key value store’s When memory is full setting defaults to Keep everything, which is the no-eviction behaviour a queue needs. The store keeps data on disk with a nightly snapshot, runs in Mumbai next to your services, and is limited by memory rather than commands. Details are in the Key Value guide.

Production checklist

  • Use separate connections for producers and workers. The library’s own production guide asks for maxRetriesPerRequest: null on workers, so they retry through a Redis restart, and for producers that fail fast, so a web request does not hang while Redis is down. The setup file above does both.
  • Bound job history with removeOnComplete and removeOnFail, as above.
  • Retry with backoff. attempts: 5 with exponential backoff absorbs a provider that is briefly down.
  • Shut down gracefully. Call worker.close() on SIGTERM so a deploy lets running jobs finish rather than leaving them stalled.
  • Keep payloads small. Put an ID in the job and load the record in the worker. Never put passwords or card data in a job.
  • Make jobs idempotent. A job can run twice after a crash or a stall. Sending the same email twice is annoying; charging a card twice is worse. Check before you act.
  • Run the worker as its own process. Deploy it separately from the web server, so a burst of jobs never slows your pages. On Voroa that is a background worker, which never sleeps; the steps are in deploying a background worker.
  • Put REDIS_URL in an environment variable, never in code. See environment variables on Voroa.

Bull, RabbitMQ or something else?

Bull is the older library by the same authors. It is in maintenance mode; start new projects on the newer one.

RabbitMQ is a dedicated message broker. It is the better choice when many different services in different languages exchange messages, or when you need complex routing. For one Node.js app that needs background jobs with retries, delays and a dashboard, a Redis queue is simpler: you probably already run Redis, and there is one less server to look after.

Cron is for work that runs on a clock, such as a nightly report. A queue is for work triggered by events. Many apps use both; a scheduled task can add jobs to the queue.

Host the queue and the worker together

Voroa runs your web service, your background worker and a Redis-compatible key value store side by side in Mumbai, with no eviction by default and a free store size to start. See the sizes on the managed Redis page.

FAQ

Does the worker need its own server? It needs its own process. It can share a machine in development, but in production run it as a separate service so it can restart and scale on its own.

Can I use it with a free Redis tier? Yes, if the tier limits memory rather than commands, keeps data on disk and does not evict. A command-metered free tier is used up by one idle worker in a few days.

Why are my jobs stuck in “waiting”? Usually no worker is running, the worker is connected to a different Redis or queue name, or the worker crashed on start. Check the worker’s logs first.

Why did Redis run out of memory? Completed jobs are kept by default. Set removeOnComplete and removeOnFail with a count or an age.

Does it work with Valkey? It relies on core commands and Lua scripts, which Valkey supports. Test with your own jobs before you switch.

Is it free? The library is open source under the MIT licence. Its authors also sell a Pro version with extra features such as groups and batches.

Measured 7 November 2026 with library version 6.7.1 and Redis 8.10.3 in WSL2 on a laptop. Found something out of date? Write to [email protected].

Advertisement