Claude Code with BullMQ: Job Queues That Survive Production
Why BullMQ without CLAUDE.md ships queues that take the system down
BullMQ is the standard Node.js job queue built on Redis. It is fast, well-maintained, and has the surface area to handle the most common asynchronous workloads: outbound email, image processing, webhook delivery, scheduled tasks, multi-step workflows. The problem is the gap between what BullMQ allows and what production tolerates. The API will happily accept a job with no idempotency key, no retry policy, no rate limit, and no concurrency bound. Each of those defaults is a way to take the system down.
Claude Code, asked to "set up a job queue with BullMQ," generates a producer that connects to Redis with default options, a worker that runs ten concurrent jobs against an upstream API with a 5 requests-per-second limit, no idempotency handling on the producer, and a retry policy that retries forever with no backoff. The first time the upstream rate-limits, the worker hammers it. The first time the worker crashes mid-job, the job runs twice. The first time Redis is briefly unreachable, the connection pool fills up and the API process refuses new HTTP requests. None of these are BullMQ bugs. They are the predictable consequences of code Claude generates without a CLAUDE.md that pins the production-safe defaults.
This guide covers the CLAUDE.md configuration that locks Claude Code into BullMQ patterns that survive production: a singleton ioredis connection shared across queues and workers, idempotency keys on every producer call, retry policies with exponential backoff and a finite attempt budget, rate limiting encoded at the worker level, graceful shutdown that drains in-flight jobs, and permission hooks that gate destructive queue operations. For the Redis configuration that BullMQ sits on top of, Claude Code with Redis covers connection pooling, eviction policies, and the pubsub patterns Claude misuses by default. For durable workflow orchestration where BullMQ is too low-level, Claude Code with Temporal covers the workflow engine that handles state machines, sagas, and long-running processes.
The BullMQ CLAUDE.md template
The CLAUDE.md at your project root is read at the start of every Claude Code session. For a BullMQ integration it needs to declare: the SDK version, the project structure, the queue topology, the producer/consumer separation, the connection pattern, the retry and rate-limit policy, the idempotency rule, the worker concurrency bound, and the hard rules that block the mistakes Claude makes most often.
# BullMQ rules
## Stack
- bullmq ^5.x
- ioredis ^5.x as the Redis client (BullMQ peer dependency)
- Redis 7.x or compatible (Upstash, Memorystore, ElastiCache)
- REDIS_URL env var pins the connection; no inline hosts/ports
- REDIS_PREFIX env var namespaces queue keys by environment
## Project structure
- src/queues/ , one file per queue (queue name, schema, options)
- src/workers/ , one file per worker (consumes a single queue)
- src/jobs/ , job handlers (pure functions called by workers)
- src/redis/ , shared Redis connection module (singleton)
- src/schedulers/ , repeatable job definitions (cron, every-N)
- tests/ , unit tests for job handlers + integration tests with real Redis
## Connection pattern (MANDATORY)
- ONE ioredis Connection instance per process, shared across queues and workers
- maxRetriesPerRequest: null for BullMQ blocking commands (required)
- enableReadyCheck: false for BullMQ workers (required, prevents reconnect storms)
- Connection module exports the singleton; queues/workers import it
## Queue definitions (MANDATORY for every Queue)
- One named queue per logical job type
- defaultJobOptions configured on the Queue, NOT per producer call:
attempts, backoff, removeOnComplete, removeOnFail, jobId pattern
- Queue name MUST include the environment prefix from REDIS_PREFIX
- Producer file exports addJob(payload, opts?) wrapping queue.add()
## Idempotency (MANDATORY for every producer call)
- ALWAYS pass jobId derived from the business key (orderId, webhookId, etc.)
- NEVER let BullMQ auto-generate jobId for queues that can receive duplicates
- Duplicate jobIds are silently dropped by BullMQ, which is the desired behaviour
## Retry policy (MANDATORY on every Queue or producer call)
- attempts: 5 for default, 3 for time-sensitive, 10 for critical (capped)
- backoff: { type: 'exponential', delay: 1000 } default
- NEVER attempts: 0 (infinite retries, queue fills, Redis OOM)
- NEVER backoff omitted (constant 1s retry hammers upstream)
## Worker concurrency and rate limiting (MANDATORY)
- concurrency: explicit number based on upstream capacity, default 5
- limiter: { max, duration } when worker calls a rate-limited upstream
- NEVER concurrency > upstream rate limit / job duration
- NEVER unbounded concurrency on a worker that does network IO
## Graceful shutdown (MANDATORY)
- Worker.close() called on SIGTERM and SIGINT
- await queue.close() before process exit
- await connection.quit() last
- Shutdown timeout: 30s for short jobs, 5min for long jobs (match k8s terminationGracePeriodSeconds)
## Removal policy
- removeOnComplete: { count: 1000 } default (keep last 1000 succeeded for debugging)
- removeOnFail: { count: 5000 } default (keep more failed for investigation)
- NEVER removeOnComplete: false in production (Redis fills up)
## Hard rules
- NEVER create more than one ioredis Connection per process for BullMQ
- NEVER call queue.add() without an idempotent jobId
- NEVER set attempts: 0 (which means infinite retries)
- NEVER configure a worker without explicit concurrency
- NEVER skip Worker.close() in the SIGTERM handler
- NEVER use the same Redis instance for BullMQ and application cache without separate prefixes
- ALWAYS log the jobId on every job event (added, completed, failed, stalled)
Six rules here prevent the majority of production incidents Claude generates without them.
The single-connection rule is the foundation. BullMQ uses blocking Redis commands (BRPOPLPUSH, BZPOPMIN) that require dedicated connections. Each worker needs its own connection for blocking reads. If Claude generates new IORedis() inside every queue or worker constructor, the process opens dozens of connections, hits Redis client limits, and the application starts refusing health checks. The singleton module forces one connection per process, with the BullMQ-specific options (maxRetriesPerRequest: null, enableReadyCheck: false) that the docs require.
The idempotency rule prevents the duplicate-execution class of bug. A producer that fires off queue.add('send-email', { userId, templateId }) with no jobId lets BullMQ auto-generate an ID. A retry from the upstream caller, a duplicate webhook, a redelivered message, all create independent jobs. The email fires twice. The webhook calls the customer twice. The Stripe charge runs twice. Adding jobId: \email:${userId}:${templateId}:${dayBucket}`` makes the second call a no-op because BullMQ rejects duplicate jobIds.
The retry policy rule prevents the failure-amplification spiral. The BullMQ default for attempts is 1: no retry, the job fails and stays failed. The first production change is usually "make it retry." Claude's instinct is attempts: 10 with no backoff. Now a failing upstream gets hammered with retries at the constant rate of the queue, which makes recovery slower and the incident larger. The correct shape is attempts: 5, backoff: { type: 'exponential', delay: 1000 }, which retries five times with exponentially increasing delays.
The worker concurrency rule is the most violated rule in production. A worker with concurrency: 50 running jobs that call a payment provider with a 10 requests-per-second limit will exceed the rate limit within milliseconds. The right pattern is to set concurrency to a number the upstream can absorb and to add limiter: { max: 10, duration: 1000 } for hard rate-limit boundaries.
Install and connection setup
Install the dependencies:
pnpm add bullmq ioredis
pnpm add -D @types/ioredis
Set the environment:
# .env.local
REDIS_URL=redis://localhost:6379
REDIS_PREFIX=app:local
Create the singleton Redis connection:
// src/redis/connection.ts
import IORedis from 'ioredis';
if (!process.env.REDIS_URL) {
throw new Error('REDIS_URL must be set');
}
export const connection = new IORedis(process.env.REDIS_URL, {
maxRetriesPerRequest: null,
enableReadyCheck: false,
});
connection.on('error', (err) => {
console.error('redis_connection_error', { message: err.message });
});
connection.on('reconnecting', (delay) => {
console.warn('redis_reconnecting', { delay });
});
The two options at the bottom are BullMQ-specific. maxRetriesPerRequest: null lets blocking commands wait indefinitely without throwing. enableReadyCheck: false skips the PING health check that interferes with the blocking commands BullMQ workers depend on. Without these, workers throw connection errors at random intervals and the queue backs up.
For Upstash, ElastiCache, or another managed Redis, set REDIS_URL to the provided connection string. For Redis Cluster, swap IORedis for IORedis.Cluster and pass the cluster nodes. The application code does not change.
Defining a queue
A queue in BullMQ has a name, a Redis connection, and a set of default job options. Define the queue once, in a dedicated file, and have producers import it.
// src/queues/email-queue.ts
import { Queue } from 'bullmq';
import { connection } from '../redis/connection';
export type EmailJob = {
userId: string;
templateId: string;
variables: Record<string, string | number>;
dayBucket: string;
};
export const emailQueue = new Queue<EmailJob>('email', {
connection,
prefix: process.env.REDIS_PREFIX ?? 'app',
defaultJobOptions: {
attempts: 5,
backoff: { type: 'exponential', delay: 1000 },
removeOnComplete: { count: 1000 },
removeOnFail: { count: 5000 },
},
});
export async function enqueueEmail(payload: EmailJob) {
const jobId = `email:${payload.userId}:${payload.templateId}:${payload.dayBucket}`;
return emailQueue.add('send', payload, { jobId });
}
Three things here that CLAUDE.md enforces. The queue takes a typed payload via the generic parameter, so the worker and the producer share a single source of truth for the job shape. The defaultJobOptions are declared once on the queue, so every producer inherits the retry policy without copy-pasting. The enqueueEmail wrapper computes the jobId from the business keys, so re-enqueuing the same email within the same day bucket is a no-op.
The dayBucket field is a manual idempotency window. A daily roll-up email keyed by userId + templateId alone would dedupe forever. Adding the day bucket lets the same template fire once per day per user, which is usually what you want.
Writing a worker
A worker is a process or a process-internal subscriber that pulls jobs off the queue and executes them. Workers must declare their concurrency, their rate limit, and a job handler that returns a result or throws.
// src/workers/email-worker.ts
import { Worker, Job } from 'bullmq';
import { connection } from '../redis/connection';
import { EmailJob } from '../queues/email-queue';
import { sendEmail } from '../jobs/send-email';
export const emailWorker = new Worker<EmailJob>(
'email',
async (job: Job<EmailJob>) => {
return sendEmail(job.data);
},
{
connection,
prefix: process.env.REDIS_PREFIX ?? 'app',
concurrency: 10,
limiter: { max: 50, duration: 1000 },
},
);
emailWorker.on('completed', (job, result) => {
console.log('email_completed', { jobId: job.id, result });
});
emailWorker.on('failed', (job, err) => {
console.error('email_failed', {
jobId: job?.id,
attemptsMade: job?.attemptsMade,
message: err.message,
});
});
emailWorker.on('stalled', (jobId) => {
console.warn('email_stalled', { jobId });
});
The handler is intentionally thin. It calls a pure function from src/jobs/send-email.ts that takes the payload and does the work. This keeps the handler unit-testable without the BullMQ machinery and lets the worker stay focused on lifecycle concerns.
The concurrency: 10 is the maximum jobs in flight per worker. The limiter: { max: 50, duration: 1000 } caps the queue-wide throughput at 50 jobs per second, which BullMQ enforces by delaying job pickup once the limit is hit. The right values come from upstream capacity: an email provider with a 100 requests-per-second limit and a typical send latency of 200 ms can sustain 20 concurrent sends without queueing internally, so concurrency: 10 per worker with two worker processes hits the target with headroom.
The three event handlers do structured logging on completed, failed, and stalled. The attemptsMade field on the failed event tells you whether this is a transient failure that will retry or the final attempt that exhausted the policy.
Graceful shutdown
Workers that exit without draining will lose in-flight jobs. BullMQ marks them as stalled and retries them on the next worker, but stalled-job recovery has a delay and the retry counter does not increment, which can mask real failures.
// src/main.ts
import { emailWorker } from './workers/email-worker';
import { emailQueue } from './queues/email-queue';
import { connection } from './redis/connection';
async function shutdown(signal: string) {
console.log(`shutdown_start`, { signal });
await emailWorker.close();
await emailQueue.close();
await connection.quit();
console.log('shutdown_complete');
process.exit(0);
}
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
Worker.close() stops accepting new jobs, waits for in-flight jobs to complete, and resolves when the worker has fully drained. Queue.close() flushes any pending producer commands. connection.quit() sends QUIT to Redis cleanly. The order matters: workers first, then queues, then the connection.
Match the terminationGracePeriodSeconds in your Kubernetes deployment to the longest-running job plus a buffer. If jobs can take five minutes and your grace period is 30 seconds, the orchestrator will SIGKILL the worker before it drains. Add a CLAUDE.md rule that requires the deployment manifest to declare the right grace period.
Repeatable jobs and schedulers
BullMQ has a built-in scheduler for repeatable jobs: cron-like recurrence, fixed intervals, delayed jobs. Use it for periodic work that is genuinely cron-shaped. For workflow-shaped recurring work, where the schedule depends on application state, Claude Code with Temporal covers the workflow engine that handles those cases more cleanly.
// src/schedulers/daily-rollup-scheduler.ts
import { Queue } from 'bullmq';
import { connection } from '../redis/connection';
const rollupQueue = new Queue('daily-rollup', {
connection,
prefix: process.env.REDIS_PREFIX ?? 'app',
});
export async function registerDailyRollup() {
await rollupQueue.add(
'daily',
{},
{
repeat: { pattern: '0 3 * * *', tz: 'UTC' },
jobId: 'daily-rollup',
},
);
}
The jobId: 'daily-rollup' is the idempotency anchor for the schedule itself. Without it, every deployment that re-runs registerDailyRollup() would create a new repeating schedule, and within a week the queue would have seven copies running daily. With the fixed jobId, re-registering is a no-op.
Add a scheduler rule to CLAUDE.md:
## Repeatable jobs
- ALWAYS pass an explicit jobId for repeatable jobs
- ALWAYS specify the timezone in repeat.tz (default UTC, never local)
- registerDailyX() functions are idempotent: safe to call on every boot
- Remove obsolete schedules with queue.removeRepeatableByKey() during deployment cleanup
Rate limiting against a brittle upstream
The default BullMQ limiter is a queue-wide rate limit: max jobs per duration across all workers attached to the queue. It is enforced by delaying job pickup, not by sleeping inside the handler. The right way to think about it: limiter: { max: 100, duration: 60_000 } means "no more than 100 jobs picked up per minute, queue-wide."
For an upstream with a token-bucket rate limit, this matches naturally. For an upstream with a per-second hard cap, set duration: 1000. For an upstream with a per-account quota where one account is throttled separately, you need a per-group limiter:
new Worker(
'webhook-delivery',
async (job) => {
return deliverWebhook(job.data);
},
{
connection,
concurrency: 50,
limiter: { max: 10, duration: 1000, groupKey: 'tenantId' },
},
);
With groupKey: 'tenantId', BullMQ applies the rate limit per tenant. A noisy tenant cannot starve quiet tenants of throughput, and one tenant's outage does not block other tenants' deliveries.
Dead letter handling
A job that exhausts its retry attempts ends up in the failed jobs set. The failure event fires, the job is retained according to removeOnFail, and that is the end of the BullMQ lifecycle. Production needs more: alerting, manual replay, and a path for human intervention.
The standard pattern is a dedicated worker that consumes the failed-jobs feed and routes failures by error class:
emailWorker.on('failed', async (job, err) => {
if (!job) return;
if (job.attemptsMade < (job.opts.attempts ?? 1)) return;
await reportToErrorTracker({
queue: 'email',
jobId: job.id,
payload: job.data,
error: err.message,
stack: err.stack,
});
if (isRetryableLater(err)) {
await deadLetterQueue.add('email', {
originalJob: job.data,
originalJobId: job.id,
failureReason: err.message,
});
}
});
The check job.attemptsMade < (job.opts.attempts ?? 1) ensures the failure handler only fires on the final, non-retryable failure. Reporting before the final attempt is noise. Routing to a dead-letter queue for retryable-later errors gives the operator a way to replay failed jobs once the upstream is healthy again.
Common Claude Code mistakes with BullMQ
Six patterns Claude generates incorrectly without CLAUDE.md constraints.
1. New connection per queue or worker
Claude generates: const queue = new Queue('email', { connection: new IORedis(process.env.REDIS_URL) }).
Correct pattern: import connection from the shared singleton module. One ioredis instance per process.
2. Producer calls without idempotent jobId
Claude generates: await queue.add('send', { userId }).
Correct pattern: await queue.add('send', { userId }, { jobId: \send:${userId}:${dayBucket}` })`.
3. Worker without concurrency
Claude generates: new Worker('email', handler, { connection }).
Correct pattern: new Worker('email', handler, { connection, concurrency: 10, limiter: { max: 50, duration: 1000 } }).
4. Retry policy without backoff
Claude generates: defaultJobOptions: { attempts: 10 }.
Correct pattern: defaultJobOptions: { attempts: 5, backoff: { type: 'exponential', delay: 1000 } }.
5. Missing shutdown handlers
Claude generates: worker process with no SIGTERM handler.
Correct pattern: SIGTERM and SIGINT handlers that call Worker.close(), Queue.close(), then connection.quit().
6. removeOnComplete: false in production
Claude generates: removeOnComplete: false "to keep all history."
Correct pattern: removeOnComplete: { count: 1000 }. Redis fills up otherwise, and BullMQ slows down as the completed set grows.
Add each pattern to CLAUDE.md with the corrected form.
Permission hooks for queue operations
BullMQ has CLI and programmatic operations that can drop jobs, flush queues, or pause workers. In development they are useful. In production they are accidents waiting to happen. Permission hooks gate the destructive ones.
In .claude/settings.local.json:
{
"permissions": {
"allow": [
"Bash(npx bullmq*)",
"Bash(redis-cli --scan*)",
"Bash(redis-cli LLEN*)",
"Bash(redis-cli ZCARD*)"
],
"deny": [
"Bash(redis-cli FLUSHDB*)",
"Bash(redis-cli FLUSHALL*)",
"Bash(redis-cli DEL bull:*)",
"Bash(npx bullmq removeAll*)"
]
}
}
The deny list catches the operations that empty queues or wipe Redis. The allow list permits the read-only inspection commands that Claude needs for debugging. Combined with the Claude Code hooks system for project-wide policy, the result is a setup where Claude can investigate a stuck queue but cannot accidentally flush it.
Observability and metrics
Every BullMQ deployment needs three sets of metrics: queue depth (waiting, active, delayed, failed), worker throughput (jobs/sec, p50/p99 duration), and Redis health (memory, connection count, commands/sec).
For queue depth, BullMQ has a built-in metrics API:
const counts = await emailQueue.getJobCounts(
'waiting',
'active',
'completed',
'failed',
'delayed',
);
Export these via a Prometheus endpoint or push them to your metrics backend on a 30-second interval. The alert thresholds that matter: waiting > 10,000 jobs (consumer is too slow), active stuck at concurrency limit for > 5 minutes (worker is hung), failed > 100 in the last hour (upstream incident), delayed > 50,000 (retry budget exhausted).
For worker throughput, instrument the handler with start/end timestamps and emit a histogram. The p99 duration tells you whether the timeout you configured is tight enough. The jobs/sec rate against the queue waiting count tells you whether you need more worker processes.
For Redis health, the standard pattern is to run Claude Code with Datadog or another APM against Redis directly. BullMQ's connection metrics flow through the ioredis events you subscribed to in the connection module. The relevant alerts: Redis memory > 80% of maxmemory, eviction count > 0 (BullMQ keys are getting evicted, jobs are being lost), connection count growing without bound (someone is creating connections per-request).
Testing BullMQ jobs
The job handler is a pure function. The worker is the BullMQ machinery. Test the handler directly:
// tests/jobs/send-email.test.ts
import { sendEmail } from '../../src/jobs/send-email';
test('sends with the right template variables', async () => {
const result = await sendEmail({
userId: 'u_1',
templateId: 't_welcome',
variables: { name: 'Test' },
dayBucket: '2026-06-01',
});
expect(result.providerId).toBeDefined();
});
For integration tests against a real queue and worker, spin up a Redis container in CI:
// tests/integration/email-queue.test.ts
import { emailQueue, enqueueEmail } from '../../src/queues/email-queue';
import { emailWorker } from '../../src/workers/email-worker';
test('a job goes from enqueue to completed', async () => {
const job = await enqueueEmail({
userId: 'u_test',
templateId: 't_test',
variables: {},
dayBucket: '2026-06-01',
});
const completed = await job.waitUntilFinished(emailWorker.queueEvents);
expect(completed).toBeDefined();
});
The integration test imports the production queue and worker, fires a single job, waits for completion, and asserts on the result. It catches the configuration mistakes the unit tests miss: wrong queue name, wrong connection prefix, idempotent jobId colliding with another test.
Building BullMQ code that does not take the site down
The BullMQ CLAUDE.md in this guide produces code where every queue has a typed payload, every producer call has an idempotent jobId, every worker declares its concurrency and rate limit, the Redis connection is a process-wide singleton with the right BullMQ options, shutdown drains in-flight work, schedulers are safe to re-register on boot, and dead-letter handling routes failures to a place a human can act on.
The underlying principle is the same as any queue infrastructure with Claude Code. BullMQ without a CLAUDE.md produces a system that works on a single dev machine and falls apart the first time the upstream rate-limits, the worker process restarts mid-job, or a duplicate webhook fires. The CLAUDE.md is what makes the production-safe defaults the default for Claude.
For Redis itself, Claude Code with Redis covers connection pooling, eviction policies, and key namespacing. For workflow orchestration where BullMQ is too low-level, Claude Code with Temporal covers the workflow engine. For event-driven background jobs triggered by application events rather than queue producers, Claude Code with Inngest covers the alternative model.
Get Claudify. Ship BullMQ workers that survive production traffic.
More like this
Ready to upgrade your Claude Code setup?
Get Claudify