Lesson 7 — Going to Production

Goal: Wire everything from Lessons 1–6 into a deployable, operable service: a Redis that won't lose or evict your jobs, workers that shut down cleanly under SIGTERM, and a scaling model that matches your workload. The last mission box.

Tied to mission: "deploy and operate in production" — and the whole point: confidence running async workloads on Redis without paging yourself at 3am. Synthesizes L1L6.


1. Redis must be configured for a queue, not a cache

This is the single most important prod setting, and it's not in your code — it's in Redis.

maxmemory-policy MUST be noeviction. BullMQ cannot work if Redis evicts keys. A cache-style policy (allkeys-lru etc.) will silently delete jobs and corrupt the queue. This is the only policy that guarantees correct behaviour.
Redis settingRecommendedWhy
maxmemory-policynoevictionNever evict queue data.
PersistenceAOF (Append-Only File), ~1s fsyncMany hosts disable persistence by default → a restart loses all pending jobs. AOF is robust + fast.
Memory headroomMonitor used_memory; size for backlog burstsWith noeviction, hitting maxmemory makes writes fail.
Many managed Redis providers default to cache settings and no persistence. Flip both before you ship. This bites everyone once.

2. Connections — the maxRetriesPerRequest: null rule

BullMQ uses blocking Redis commands. ioredis's default (maxRetriesPerRequest) blocks BullMQ from working at all. You must null it:

import IORedis from 'ioredis';
const connection = new IORedis({ maxRetriesPerRequest: null });
ClassConnections it holds
Queue1 (can share)
Worker1 + 1 duplicate internally (the duplicate runs blocking commands)
QueueEvents1 + 1 duplicate internally (blocking)

So you can pass one ioredis instance to a Queue + Worker — but the Worker/QueueEvents internally duplicate() it for their blocking connection. The instance must support duplicate() (ioredis does). Redis connections are cheap; don't over-optimize sharing unless your provider caps you.

Isolate environments with prefix

new Queue('email', { prefix: 'myapp-prod', connection });
// keys become bull:myapp-prod:... instead of bull:email:...

Lets staging + prod share one Redis without colliding — though separate Redis instances is cleaner.

3. Graceful shutdown — worker.close() + your own timeout

On SIGTERM (deploy, k8s rolling update, scale-down), you want in-flight jobs to finish — not become stalled.

let shuttingDown = false;

async function shutdown() {
  if (shuttingDown) return;
  shuttingDown = true;
  console.log('draining worker...');
  await worker.close();   // stops picking new jobs; waits for active ones
  await queue.close();
  await connection.quit();
  process.exit(0);
}

process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);
worker.close() does NOT self-timeout. It waits forever for active jobs to finish. But your orchestrator (k8s) sends SIGKILL after a grace period (~30s). If a job runs longer, it's killed mid-flight → stalled. Two fixes: (a) keep jobs short; (b) race worker.close() against a timer and force-exit, accepting that long jobs will stall and be re-queued by another worker (that's the safety net).

4. Concurrency vs parallelism — don't conflate them

KnobWhat it scalesGood for
concurrency: N (Worker option)Jobs in flight per worker, via the event loopI/O-bound work (HTTP, DB). Crank it up.
Multiple Workers / processesTrue parallelism across CPUs / machinesCPU-bound work. Scales ~linearly with workers.
Raising concurrency on CPU-heavy jobs lowers throughput. It just adds context-switching overhead. For CPU work, keep concurrency low and scale by adding worker processes/instances instead.

CPU-bound work → sandboxed processors

If a job blocks the event loop (image resize, crypto, big parses), it starves the heartbeat → stalls (Lesson 6). Move it to a sandboxed processor — runs in a separate process or thread:

// worker.ts — the host
import { Worker } from 'bullmq';
new Worker('images', undefined, {
  connection,
  useWorkerThreads: true,        // v3.13+: threads (lighter) instead of spawn
  authorURL: import.meta.url,    // points to the processor file
});

// processor.ts — separate file, runs in the sandbox
import { parentPort } from 'worker_threads';
module.exports = async (job) => { /* heavy CPU here, isolated */ };

Crash in the sandbox doesn't kill the worker; the event loop of the host stays free to renew locks.

5. Make jobs idempotent

Retries (L2), rate-limit re-queues (L4), stalled re-runs (L6), and flow resumption (L5) all mean the same job can execute more than once. Design for it:

A job that "sends an email then marks sent" is not idempotent — a stall mid-send can double-send. Idempotency is not optional in a system that retries.

6. The production checklist

7. Full picture — a production-shaped service

import { Queue, Worker, QueueEvents, MetricsTime } from 'bullmq';
import IORedis from 'ioredis';

const connection = new IORedis({ maxRetriesPerRequest: null });

const email = new Queue('email', {
  connection,
  defaultJobOptions: {
    attempts: 5,
    backoff: { type: 'exponential', delay: 2000, jitter: 0.5 },
    removeOnComplete: { age: 86_400, count: 2000 },
    removeOnFail:    { age: 604_800, count: 5000 },
  },
});

// recurring digest — safe to call every boot
await email.upsertJobScheduler('daily-digest',
  { pattern: '0 0 9 * * 1-5' },
  { name: 'digest', data: {} });

const worker = new Worker('email', async (job) => {
  if (await alreadySent(job.id)) return;        // idempotent
  await sendMail(job.data);
}, {
  connection,
  concurrency: 20,                              // I/O-bound → high
  stalledInterval: 30_000,
  maxStalledCount: 1,
  metrics: { maxDataPoints: MetricsTime.ONE_WEEK * 2 },
});
worker.on('stalled', (id) => alert('stalled', id));

let shuttingDown = false;
async function shutdown() {
  if (shuttingDown) return; shuttingDown = true;
  await worker.close(); await connection.quit(); process.exit(0);
}
process.on('SIGTERM', shutdown).on('SIGINT', shutdown);
🎓 Mission complete. You can now: explain BullMQ's architecture (L1); retry with backoff + DLQ (L2); schedule delayed & cron work (L3); prioritize and rate-limit (L4); model multi-step pipelines with flows (L5); observe and self-clean (L6); and operate it all in production (L7). Every success box in MISSION.md is now checkable.

What you just learned


🧠 Quiz — 10 questions

1. Required Redis maxmemory-policy for BullMQ?

2. Which ioredis option is mandatory?

3. Recommended Redis persistence for BullMQ?

4. What does worker.close() do with active jobs?

5. Does worker.close() self-timeout?

6. Which signal triggers graceful shutdown in k8s?

7. High concurrency is best for what work?

8. How do you scale throughput for CPU-bound jobs?

9. Why must jobs be idempotent in production?

10. What isolates staging from prod on shared Redis?


📚 Primary sources

Going to Production · Connections · Parallelism & Concurrency · Graceful Shutdown · Sandboxed Processors (official). Redis: Persistence.

📎 Reference

Production Cheatsheet — the launch checklist + connection/shutdown/scale rules on one screen.

💬 Where to go from here

This is the end of the core curriculum. Ask me anything — or tell me a new direction and I'll plan the next arc.