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.
Recipe
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:
- Async email, webhooks, PDF generation, search indexing
- Rate-limited third-party API calls via worker concurrency
- Delayed jobs and cron replacements with BullMQ repeatable jobs
- Cache hot reads with ioredis while jobs write through to Postgres
Working Example
// 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:
- BullMQ uses Redis streams and keys under
bull:prefix by default - Application cache uses
cache:prefix - no key collisions - Run workers as separate deployments (
node dist/workers/email.js) - HTTP pods use
maxRetriesPerRequestdefault for cache; workers usenull
Operations Checklist
| 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 });
}
});Comparison
| 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 |
FAQs
Same Redis for cache and queues?
Yes on small/medium stacks with key prefixes and memory limits. At scale, split cache (eviction OK) from queue (no eviction) Redis instances.
How many connections per pod?
One shared ioredis for cache per HTTP pod; workers need dedicated connections per Worker instance. Watch connected_clients on Redis.
BullMQ vs node-cron?
Use BullMQ repeatable jobs when multiple replicas must not double-fire cron. Use node-cron only with leader election or single scheduler pod.
Related
- BullMQ - deep queue patterns
- ioredis - client tuning
- Queue Worker Skill - agent playbook
- Essential Libraries Basics - default pairing
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.