# BullMQ Repeatable Jobs and Job Schedulers: What Breaks in Production

By Dan Ochoa · Published 2026-10-25 · Updated 2026-10-25
Canonical: https://cronradar.com/blog/bullmq-repeatable-jobs

> How BullMQ Job Schedulers replaced repeatable jobs, what happens after downtime, why jobs stall, and how to alert when a scheduled job fails or never runs.

**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](https://github.com/taskforcesh/bullmq/releases/tag/v6.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](https://cronradar.com/blog/node-js-cron-monitoring). To see how BullMQ compares with Agenda and the in-process libraries, see [Node.js schedulers compared](https://cronradar.com/blog/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](https://github.com/taskforcesh/bullmq/releases/tag/v6.0.0) that Redis users install explicitly:

```bash
npm install bullmq ioredis
```

```javascript
import { 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](https://docs.bullmq.io/guide/job-schedulers), 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](https://github.com/taskforcesh/bullmq/blob/v6.4.2/src/classes/worker.ts#L989-L1014)), 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](https://www.npmjs.com/package/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](https://docs.bullmq.io/guide/job-schedulers/repeat-strategies) 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](https://github.com/harrisiirak/cron-parser/blob/v5.10.2/src/CronDate.ts#L34-L35)), 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'`](https://docs.bullmq.io/guide/job-schedulers/repeat-options).

### Intervals, Immediate Runs, and Limits

- `every` takes milliseconds and can't be combined with `pattern`. A new `every` scheduler runs its first job immediately (the `immediately` flag 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](https://github.com/taskforcesh/bullmq/blob/v6.4.2/src/commands/includes/getJobSchedulerEveryNextMillis.lua)), although the repeat options page still describes clock alignment.
- `immediately: true` on a `pattern` scheduler makes the next run happen now. Left in startup code, it adds an off-schedule run every time the upsert executes ([job-scheduler.ts](https://github.com/taskforcesh/bullmq/blob/v6.4.2/src/classes/job-scheduler.ts#L517-L539)).
- `startDate`, `endDate`, and `limit` bound the schedule; past `endDate` or `limit`, 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](https://docs.bullmq.io/guide/migrations/migrate-from-v5-to-v6)). 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](https://github.com/taskforcesh/bullmq/blob/v6.4.2/src/classes/worker.ts#L1019-L1026)). 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](https://github.com/taskforcesh/bullmq/blob/v6.4.2/src/classes/job-scheduler.ts#L23-L28)). 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](https://github.com/taskforcesh/bullmq/blob/v6.4.2/src/classes/job-scheduler.ts#L187-L188)); for `every`, the next slot after now ([getJobSchedulerEveryNextMillis.lua](https://github.com/taskforcesh/bullmq/blob/v6.4.2/src/commands/includes/getJobSchedulerEveryNextMillis.lua#L22-L37)). The [legacy repeatable docs](https://docs.bullmq.io/guide/jobs/repeatable) 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](https://cronradar.com/blog/missed-runs-after-downtime), and make reruns safe with [idempotent scheduled jobs](https://cronradar.com/blog/idempotent-scheduled-jobs).

_Figure: The scheduler keeps one delayed job, and the worker that picks up a run creates the next. Three days without a worker produce one late run, not three, and the next run is calculated from the current time._

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](https://github.com/taskforcesh/bullmq/blob/v6.4.2/src/commands/addJobScheduler-11.lua#L120-L138)). 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)`](https://docs.bullmq.io/guide/queues/global-concurrency); [preventing overlapping cron jobs](https://cronradar.com/blog/prevent-overlapping-cron-jobs) covers lock-based alternatives.

### Redis Must Not Evict Keys

BullMQ's [production guide](https://docs.bullmq.io/guide/going-to-production) 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](https://github.com/taskforcesh/bullmq/blob/v6.4.2/src/classes/redis-connection.ts#L873-L879)). 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](https://redis.io/docs/latest/develop/reference/eviction/)), 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](https://docs.bullmq.io/guide/queues/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](https://github.com/taskforcesh/bullmq/blob/v6.4.2/src/interfaces/worker-options.ts)). 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](https://docs.bullmq.io/guide/workers/stalled-jobs)). Move heavy work into a [sandboxed processor](https://docs.bullmq.io/guide/workers/sandboxed-processors), which runs in a separate process (or a worker thread with `useWorkerThreads`), or break it up so it yields.

The [stalled page](https://docs.bullmq.io/guide/jobs/stalled) 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](https://github.com/taskforcesh/bullmq/blob/v6.4.2/src/commands/moveStalledJobsToWait-9.lua#L74-L99)). 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](https://github.com/taskforcesh/bullmq/blob/v6.4.2/src/classes/worker.ts#L1157-L1169)).

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](https://cronradar.com/blog/cron-job-timeout) 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](https://docs.bullmq.io/guide/retrying-failing-jobs)). A job is retried while `attemptsMade + 1 < attempts` ([job.ts](https://github.com/taskforcesh/bullmq/blob/v6.4.2/src/classes/job.ts#L706-L721)), which is how the processor below recognizes its last attempt.

The events differ in a way that matters for alerting ([worker.ts](https://github.com/taskforcesh/bullmq/blob/v6.4.2/src/classes/worker.ts), [moveToFinished-14.lua](https://github.com/taskforcesh/bullmq/blob/v6.4.2/src/commands/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](https://cronradar.com/blog/cron-job-retries) 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:

```javascript
// 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 send `15 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.
- **`every` of 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 * * * *` for `every: 300_000`, or keep `every` and set the monitor's grace period to at least the interval.
- **Sub-minute schedules**, such as `every: 10_000` or `*/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.

```javascript
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.
