BullMQ Repeatable Jobs and Job Schedulers: What Breaks in Production
Contents
In current BullMQ, a repeatable job is a Job Scheduler: queue.upsertJobScheduler(id, { pattern or every }, template) creates or updates the schedule by ID, and BullMQ keeps exactly one delayed job waiting for the next run. The older API, queue.add(name, data, { repeat }), was deprecated in BullMQ 5.16.0 and removed in 6.0.0 on July 30, 2026. The failures that hurt are quiet ones: no worker running, a schedule gone from Redis, a processor that never returns. None of them produces a failed event, so something outside the process has to notice.
This guide covers BullMQ 6.4.2, the latest release on npm (checked October 25, 2026), with notes for the still-maintained 5.x line. For in-process schedulers such as node-cron, see Node.js cron job monitoring. To see how BullMQ compares with Agenda and the in-process libraries, see Node.js schedulers compared.
How a Job Scheduler Produces Each Run#
BullMQ 6 no longer installs ioredis for you; it is an optional peer dependency that Redis users install explicitly:
npm install bullmq ioredisimport { Queue } from 'bullmq'
const queue = new Queue('reports', { connection: { host: '127.0.0.1', port: 6379 } })
// Every day at 02:30 UTC. Calling this again with the same ID updates it.
await queue.upsertJobScheduler(
'nightly-invoice-export',
{ pattern: '30 2 * * *', tz: 'UTC' },
{ name: 'invoice-export', data: { format: 'csv' }, opts: { attempts: 3 } },
)The call returns the first job, in the delayed state. From then on, per the Job Schedulers guide, there is always one delayed job for the scheduler, and "the scheduler will only generate new jobs when the last job begins processing." The worker that picks up a run creates the next one (worker.ts), so a busy queue delays later runs. Each run gets the ID repeat:<scheduler id>:<timestamp>; you cannot set a custom job ID.
Pattern Syntax and Time Zones#
Patterns are parsed by cron-parser (BullMQ 6.4.2 depends on cron-parser 5.10.2). Five fields work as in crontab, and an optional sixth field at the front is seconds: the repeat strategies page writes 09:00 on weekdays as 0 0 9 * * 1-5. cron-parser also accepts L, #, ?, H, 7 for Sunday, and shortcuts such as @daily.
Set tz explicitly. Without it, cron-parser builds dates in the local zone of the process doing the calculation (CronDate.ts), and that is the producer for the first run and each worker after that. A fleet with mixed TZ settings computes different run times. BullMQ 6 removed the old utc option; use tz: 'UTC'.
Intervals, Immediate Runs, and Limits#
everytakes milliseconds and can't be combined withpattern. A neweveryscheduler runs its first job immediately (theimmediatelyflag was deprecated for this in 5.19.0), and in 6.4.2 later runs are spaced from that first run rather than aligned to the clock (getJobSchedulerEveryNextMillis.lua), although the repeat options page still describes clock alignment.immediately: trueon apatternscheduler makes the next run happen now. Left in startup code, it adds an off-schedule run every time the upsert executes (job-scheduler.ts).startDate,endDate, andlimitbound the schedule; pastendDateorlimit, no more jobs are produced.
Repeatable Jobs Are Gone in BullMQ 6#
BullMQ 6 removed Queue.add(..., { repeat }), addBulk with repeat, the Repeat class, getRepeatableJobs(), removeRepeatable(), and removeRepeatableByKey() (migration guide). Legacy repeatable data left in Redis is not converted. When a 6.x worker picks up a job from a legacy repeatable, it runs that job, emits an error event that begins "Failed to add repeatable job for next iteration", and schedules nothing after it (worker.ts). Without an error listener, the schedule ends quietly after one more run.
Migrate while still on the latest 5.x release: read each definition with getRepeatableJobs(), recreate it with upsertJobScheduler(), confirm it with getJobSchedulers(), remove the old one with removeRepeatableByKey(), and only then deploy 6.x producers and workers.
Duplicate Schedules: Old Keys and Renamed IDs#
A legacy repeatable was identified by a key built from its name, ID, end date, time zone, and pattern (job-scheduler.ts). Change the pattern in a deploy and add created a second repeatable while the first kept firing, until someone removed it by key.
upsertJobScheduler fixes that for a stable ID: it replaces the pending job and stores the new settings. But schedulers live in Redis, not in your code, so renaming an ID creates a second scheduler, and deleting the upsert call removes nothing. The example below reconciles the queue against its definitions on every start.
Missed Runs Collapse Into One After Downtime#
When no worker is running, the scheduler's single delayed job just waits. When a worker returns, that overdue job runs once, late, and the next run is calculated from the current time: for a pattern, the next occurrence after now (job-scheduler.ts); for every, the next slot after now (getJobSchedulerEveryNextMillis.lua). The legacy repeatable docs say the same of the old API: jobs "will not accumulate". A daily job that was down for three days runs once. If each missed day must be processed, make the job work from a watermark, as described in missed runs after downtime, and make reruns safe with idempotent scheduled jobs.
Upserting at startup can lose even that one late run. For a pattern scheduler, an upsert that finds the current run still delayed or waiting removes it and schedules the next future occurrence instead (addJobScheduler-11.lua). If the process that crashed at 02:25 restarts at 02:45 and upserts before its worker picks up the job, the 02:30 run never happens. The example below skips the upsert when the definition hasn't changed.
Because the next run is created when the current one starts, a run longer than the interval can overlap the next whenever you have several workers or concurrency above 1. For work that must never overlap, give it its own queue and call queue.setGlobalConcurrency(1); preventing overlapping cron jobs covers lock-based alternatives.
Redis Must Not Evict Keys#
BullMQ's production guide is blunt: set maxmemory-policy to noeviction, "the only setting that guarantees the correct behavior of the queues." BullMQ checks on connect and prints IMPORTANT! Eviction policy is allkeys-lru. It should be "noeviction" when it isn't (redis-connection.ts). Under an evicting policy, Redis can drop the keys that hold a scheduler or its pending job, and the schedule can stop without a clear error.
noeviction moves the problem: when memory is full, Redis returns an error for commands that add data (Redis eviction docs), so new jobs can't be written. Finished jobs are kept forever by default, so cap them with removeOnComplete and removeOnFail in the scheduler's template (auto-removal of jobs). The same guide recommends AOF persistence. Many hosted Redis instances don't persist by default, and a restart without persistence loses every scheduler until your code upserts them again.
Stalled Jobs and Processors That Never Return#
A worker holds a lock on each active job: lockDuration (30 seconds by default) is renewed on a timer at half that interval, and every stalledInterval (30 seconds) a check moves jobs with an expired lock back to waiting and emits stalled (WorkerOptions). The usual cause is a CPU-bound processor: synchronous work that holds the event loop for more than 30 seconds stops the renewal timer, so another worker starts the same job while the first may still be running it (stalled jobs). Move heavy work into a sandboxed processor, which runs in a separate process (or a worker thread with useWorkerThreads), or break it up so it yields.
The stalled page says a job that stalls more than maxStalledCount times (default 1) fails with "job stalled more than allowable limit". Jobs produced by a scheduler are exempt: since BullMQ 5.58.8 they go back to waiting after every stall, and since 5.78.1 only while their scheduler still exists (moveStalledJobsToWait-9.lua). A scheduled job that crashes its worker can loop indefinitely. The worker option maxStartedAttempts (BullMQ 5.59.0 and later) caps it: a job started more times than that fails with "job started more than allowable limit" before the processor runs (worker.ts).
The opposite case doesn't stall at all. An async processor waiting on a request or connection that never settles leaves the event loop free, so the lock renews indefinitely and the job stays active. With the default concurrency of 1, that worker takes no other job, including the scheduler's next run. Give the job's own I/O a deadline, for example AbortSignal.timeout(), so a hang becomes an error that BullMQ retries or fails. Cron job timeouts covers finding what a hung job waits on.
Retries, Failed Jobs, and Which Events Fire#
Set attempts and backoff in the scheduler template's opts. Without a backoff, retries are immediate, and throwing UnrecoverableError skips the remaining attempts (retrying failing jobs). A job is retried while attemptsMade + 1 < attempts (job.ts), which is how the processor below recognizes its last attempt.
The events differ in a way that matters for alerting (worker.ts, moveToFinished-14.lua):
| Event | Fires when |
|---|---|
Worker failed |
Any attempt throws, including one that will be retried |
QueueEvents failed |
The job lands in the failed set |
QueueEvents retries-exhausted |
The failed job has used all its attempts |
Worker error |
Connection problems, and failures to schedule a scheduler's next run |
Alerting on the worker's failed event pages you for failures a retry would fix; retrying failed cron jobs covers choosing what to alert on. All of these need a running worker: with no worker, or no scheduler, nothing fires.
Monitoring BullMQ Job Schedulers With CronRadar#
A schedule-aware monitor works from the other direction: it expects each scheduler's run at its scheduled time and alerts when the report is late, failed, or never finishes. There is no CronRadar SDK you can install for Node.js today and no BullMQ integration, so nothing is discovered automatically. Each scheduler reports over HTTP from the worker's processor to its own monitor, keyed by the scheduler ID.
This complete ES module (Node.js 20 or later, BullMQ 6.4) reconciles the schedulers, upserts only changed ones, and reports start, then complete or fail. Reports time out after five seconds and log instead of throwing; the job's own error is always rethrown, so retries behave as before:
// scheduled-jobs.mjs
import { Queue, Worker, UnrecoverableError } from 'bullmq'
const connection = { host: process.env.REDIS_HOST ?? '127.0.0.1', port: 6379 }
const QUEUE_NAME = 'scheduled'
const MAX_STARTS = 5 // caps re-runs after stalls; must exceed attempts
// One entry per scheduler: five-field cron in UTC, which BullMQ and CronRadar read alike.
const schedules = new Map([
[
'nightly-invoice-export',
{ pattern: '30 2 * * *', timeoutMs: 20 * 60_000, run: exportInvoices },
],
])
const jobOptions = {
attempts: 3,
backoff: { type: 'exponential', delay: 60_000 },
removeOnComplete: { count: 200 },
removeOnFail: { count: 1000 },
}
async function exportInvoices(signal) {
const response = await fetch('https://billing.example.com/api/exports/invoices', {
method: 'POST',
signal,
})
if (!response.ok) throw new Error(`Invoice export returned HTTP ${response.status}`)
}
const PING_URL = 'https://cron.life/api/v1/ping'
// Monitoring must never break the job: 5-second timeout, errors logged, never thrown.
async function checkIn(key, event, params) {
const apiKey = process.env.CRONRADAR_API_KEY
if (!apiKey) return
const path = event === 'heartbeat' ? '' : `/${event}`
const url = `${PING_URL}/${encodeURIComponent(key)}${path}?${new URLSearchParams(params)}`
try {
const response = await fetch(url, {
method: 'POST',
headers: { Authorization: `Basic ${Buffer.from(`${apiKey}:`).toString('base64')}` },
signal: AbortSignal.timeout(5000),
})
if (!response.ok) console.error(`CronRadar ${event} for ${key}: HTTP ${response.status}`)
} catch (error) {
console.error(`CronRadar ${event} for ${key} failed: ${error.message}`)
}
}
const queue = new Queue(QUEUE_NAME, { connection })
queue.on('error', (error) => console.error('Queue error:', error.message))
// This file owns every scheduler on the queue: remove any it no longer defines.
for (const scheduler of await queue.getJobSchedulers()) {
if (!schedules.has(scheduler.key)) await queue.removeJobScheduler(scheduler.key)
}
for (const [id, { pattern }] of schedules) {
const current = await queue.getJobScheduler(id)
// An upsert replaces the pending run. Skipping unchanged schedulers keeps
// a restart from dropping a run that an outage left overdue.
if (current?.pattern === pattern && current.tz === 'UTC') continue
await queue.upsertJobScheduler(id, { pattern, tz: 'UTC' }, { opts: jobOptions })
}
const worker = new Worker(
QUEUE_NAME,
async (job) => {
const id = job.repeatJobKey
const definition = schedules.get(id)
if (!definition) throw new UnrecoverableError(`No scheduler definition for ${job.name}`)
const params = {
schedule: definition.pattern,
gracePeriod: '600',
run_id: job.id, // repeat:<scheduler id>:<timestamp>, the same across retries
attempt: String(job.attemptsMade),
}
await checkIn(id, 'start', params)
try {
await definition.run(AbortSignal.timeout(definition.timeoutMs))
} catch (error) {
// Mirror BullMQ's retry decision so only the last attempt reports a failure.
const lastAttempt =
error instanceof UnrecoverableError ||
job.attemptsMade + 1 >= (job.opts.attempts ?? 1) ||
job.attemptsStarted >= MAX_STARTS
if (lastAttempt) {
const message = `${id} failed on attempt ${job.attemptsMade + 1}: ${error.name}`
await checkIn(id, 'fail', { ...params, message })
}
throw error
}
await checkIn(id, 'complete', params)
},
{ connection, maxStartedAttempts: MAX_STARTS },
)
worker.on('error', (error) => console.error('Worker error:', error.message))
async function shutdown() {
await worker.close()
await queue.close()
process.exit(0)
}
process.on('SIGTERM', shutdown)
process.on('SIGINT', shutdown)The schedule on each check-in makes the monitor self-registering: the first run creates it, and a deleted monitor comes back on the next run. schedule and gracePeriod apply only when a check-in creates the monitor, so change them later in the monitor's settings. Keep the failure message generic; error details can carry data that shouldn't leave your system. Store CRONRADAR_API_KEY in your secret manager, with one CronRadar application and key per environment.
What the Monitor Catches#
| Failure | What BullMQ shows | What the monitor does |
|---|---|---|
| No worker running, or Redis unreachable | Nothing; no process emits events | No start by the expected time: warning, critical |
| Scheduler removed, renamed, or evicted | It's missing from getJobSchedulers() |
The same missed-run alert |
| Last attempt failed | The job is in the failed set | The fail check-in opens an incident immediately |
| Processor hangs, or a stall loop is cut off | Job active, or failed by the start cap | A start with no finish: critical as stuck, see below |
The overdue check runs once a minute. A monitor turns warning once its expected time passes and becomes critical, with an incident, only when it is past the grace period and at least two minutes late. The 600-second grace here absorbs time a run spends waiting in a busy queue.
Stuck detection needs the start check-in. Every check-in, start included, moves the expected time to the scheduler's next occurrence. If that time plus the grace period passes while the last event was a start with no complete or fail, the monitor goes critical as stuck. The window is per scheduler, not per run: the next run's start resets it, so a hang is caught only when the scheduler then goes silent, which for a daily job is a day later. CronRadar has no per-job maximum-runtime setting; the AbortSignal.timeout inside the job is what turns a hang into a prompt fail.
When you retire a scheduler on purpose, including one that ends through endDate or limit, pause or delete its monitor, or it will report missed runs.
Schedules CronRadar Can't Read#
CronRadar reads standard five-field cron, evaluated in UTC. It rejects a seconds field, ?, L, #, 7 for Sunday, @-shortcuts, and intervals, so translate BullMQ schedules before sending them:
- Six fields with a fixed second, such as
0 15 3 * * *: drop the seconds and send15 3 * * *. - A non-UTC
tz: send the UTC equivalent. If the zone observes daylight saving time, use the earlier of its two UTC times and a grace period of at least an hour plus the job's usual duration. everyof a minute or more: its runs are anchored to the first run, not the clock, so they won't match a cron expression. Switch to an equivalent pattern, such as*/5 * * * *forevery: 300_000, or keepeveryand set the monitor's grace period to at least the interval.- Sub-minute schedules, such as
every: 10_000or*/10 * * * * *: monitor* * * * *and send a plain heartbeat at most once per clock minute after a successful run. A heartbeat detects missed runs but not duration or stuck runs.
let lastHeartbeatMinute = -1
// In the processor of a sub-minute scheduler, after the work succeeds:
const minute = Math.floor(Date.now() / 60_000)
if (minute !== lastHeartbeatMinute) {
lastHeartbeatMinute = minute
await checkIn('queue-poller', 'heartbeat', { schedule: '* * * * *' })
}Check-ins are limited to 60 requests a minute and 1,000 an hour per API key: that heartbeat uses up to 60 an hour per worker process, and per-minute start and complete reporting uses 120.
Frequently Asked Questions#
What replaced BullMQ repeatable jobs?#
Job Schedulers, added in BullMQ 5.16.0 and managed with upsertJobScheduler, getJobSchedulers, and removeJobScheduler. BullMQ 6.0.0 removed the repeat option on queue.add and the methods that managed it, so code using them must migrate before upgrading.
Does BullMQ run missed repeatable jobs after downtime?#
Not all of them. The overdue job runs once, late, and the schedule continues from the current time, so the runs missed in between are lost. For a pattern scheduler, an upsert at startup can drop even that late run.
Why is my BullMQ job stalled?#
Its worker didn't renew the job's lock in time, usually because synchronous CPU work blocked the event loop for more than 30 seconds, or the process died mid-job. The job goes back to waiting and runs again. Use sandboxed processors for CPU-heavy work and close workers on SIGTERM.
How do I stop duplicate repeatable jobs in BullMQ?#
Use upsertJobScheduler with a stable ID; the same ID updates the existing scheduler instead of adding one. Renaming an ID leaves the old scheduler running in Redis, so compare getJobSchedulers() with your definitions on deploy and remove the extras.
Does CronRadar auto-discover BullMQ job schedulers?#
No. There is no installable CronRadar SDK for Node.js today and no BullMQ auto-discovery. Each scheduler reports with HTTP check-ins from its processor, and the first check-in that carries a schedule creates its monitor.
Put this guide to work
Connect your Node.js job to monitoring
Use the working HTTP reporting path in the guide while the SDK is unavailable. This requires explicit instrumentation and does not discover jobs automatically.
The Node.js SDK is temporarily unavailable: the published package cannot load because it is missing a required file. Use manual HTTP reporting below. This setup does not discover jobs automatically.