bullmq / ioredis
BullMQ runs job queues on Redis; ioredis is the Node client both queues and cache layers share. Pair them with clear key namespaces and connection pooling.
Search across all documentation pages
BullMQ runs job queues on Redis; ioredis is the Node client both queues and cache layers share. Pair them with clear key namespaces and connection pooling.
Quick-reference recipe card - copy-paste ready.
// src/redis.ts - single connection factory
import IORedis from "ioredis";
export function createRedis() {
return new IORedis(process.env.REDIS_URL!, {
maxRetriesPerRequest: null, // required for BullMQ workers
enableReadyCheck: false,
});
}// src/queues/email.queue.ts
import { Queue } from "bullmq";
import { createRedis } from "../redis.js";
const connection = createRedis();
export const emailQueue = new Queue("email", {
connection,
defaultJobOptions: {
attempts: 5,
backoff: { type: "exponential", delay: 2000 },
removeOnComplete: 1000,
removeOnFail: false, // keep for DLQ inspection
},
});
await emailQueue.add("send-welcome", { userId: "abc", template: "welcome" });// src/workers/email.worker.ts
import { Worker } from "bullmq";
import { createRedis } from "../redis.js";
const worker = new Worker(
"email",
async (job) => {
await sendEmail(job.data);
},
{ connection: createRedis(), concurrency: 10 }
);
worker.on("failed", (job, err) => {
console.error({ jobId: job?.id, err }, "email_job_failed");
});When to reach for this:
// src/cache.ts
import type IORedis from "ioredis";
const CACHE_PREFIX = "cache:";
export async function cacheGet<T>(
redis: IORedis,
key: string
): Promise<T | null> {
const raw = await redis.get(`${CACHE_PREFIX}${key}`);
return raw ? (JSON.parse(raw) as T) : null;
}
export async function cacheSet(
redis: IORedis,
key: string,
value: unknown,
ttlSeconds: number
): Promise<void> {
await redis.set(
`${CACHE_PREFIX}${key}`,
JSON.stringify(value),
"EX",
ttlSeconds
);
}// HTTP handler: cache-aside pattern
import { createRedis } from "./redis.js";
import { cacheGet, cacheSet } from "./cache.js";
const redis = createRedis();
app.get("/products/:id", async (req, reply) => {
const { id } = req.params;
const cached = await cacheGet<Product>(redis, `product:${id}`);
if (cached) return cached;
const product = await db.findProduct(id);
await cacheSet(redis, `product:${id}`, product, 300);
return product;
});Architecture notes:
bull: prefix by defaultcache: prefix - no key collisionsnode dist/workers/email.js)maxRetriesPerRequest default for cache; workers use null| Signal | Tool | Action |
|---|---|---|
| Queue backlog | BullMQ getJobCounts() | Scale workers |
| Stalled jobs | BullMQ events | Investigate long CPU tasks |
| Redis memory | INFO memory | Lower removeOnComplete, TTL cache |
| Failed jobs | Bull Board / Redis CLI | Replay or move to DLQ |
// Dead letter after max attempts
import { Queue } from "bullmq";
export const dlq = new Queue("email-dlq", { connection: createRedis() });
worker.on("failed", async (job, err) => {
if (job && job.attemptsMade >= (job.opts.attempts ?? 1)) {
await dlq.add("failed", { original: job.data, error: err.message });
}
});| Approach | Choose when | Skip when |
|---|---|---|
| BullMQ + ioredis | Redis already in stack, delayed jobs | SQS-only AWS with no Redis |
| SQS + Lambda | Serverless burst workloads | Long-running CPU jobs in-process |
| pg-boss | Postgres-only shops, no Redis | Sub-millisecond job latency needs |
| Raw Redis lists | Extreme minimalism | Need retries, scheduling, UI |
Yes on small/medium stacks with key prefixes and memory limits. At scale, split cache (eviction OK) from queue (no eviction) Redis instances.
One shared ioredis for cache per HTTP pod; workers need dedicated connections per Worker instance. Watch connected_clients on Redis.
Use BullMQ repeatable jobs when multiple replicas must not double-fire cron. Use node-cron only with leader election or single scheduler pod.
Stack versions: This page was written for Node.js 24.18.0 (Active LTS), npm 10+, TypeScript 5.6+, Express 5, Fastify 5, and NestJS 11.
Reviewed by Chris St. John·Last updated Jul 16, 2026