The upgrade that quietly stops your recurring jobs
BullMQ v6 landed on 30 July 2026, and it is not a drop-in upgrade if you run recurring jobs. The repeat option that scheduled a job every night, the Repeat class, and the whole Repeatable Jobs API are gone. Not deprecated with a warning. Removed. Leave your old enqueue code in place and the upgrade compiles, the queue starts, workers report healthy. The nightly job never fires again.
This is the failure BullMQ shares with every scheduler: nothing errors. You notice when a downstream report is three weeks stale.
What changed in BullMQ v6
Repeatable jobs are gone, not deprecated
v6 removed the repeat option from Queue.add and Queue.addBulk, deleted the Repeat class, and dropped getRepeatableJobs, removeRepeatable, and removeRepeatableByKey. The same release made the queue backend pluggable across Redis and Postgres, so the upgrade often rides along with a backend swap and the scheduling regression hides in the noise.
Job Schedulers are the replacement
Recurring work now goes through a Job Scheduler, created with upsertJobScheduler. It owns a repeat rule and a job template, and produces the jobs on cadence. The verb matters: upsert, not add, so redeploying the same scheduler id updates the rule in place instead of stacking a second schedule on it.
Why the migration fails silently
The repeat option becomes a no-op
TypeScript catches a leftover repeat option, because the type no longer allows it. Plain JavaScript catches nothing: the property is ignored, add treats the call as a one-off, and the job runs once and never again. A queue that fired every hour fires one last time on deploy and goes quiet.
The pattern field counts seconds
BullMQ parses cron patterns with cron-parser, bumped to 5.7.0 in v6.0.6 on 3 August 2026. cron-parser accepts an optional sixth field for seconds at the front, so 0 15 3 * * * means 03:15:00, not a quarter past three every hour. A five-field crontab line still works, but paste one that already carries a seconds column and every field shifts a place. Check the parsed next-run time before you trust a copied expression.
One scheduler, one delayed job
A Job Scheduler keeps exactly one job in the delayed state, and produces the next occurrence only when the current one starts processing. On a healthy queue that is invisible. On a congested queue, where the previous job is stuck behind a backlog or wedged in active after a crash, the next run is never produced. The schedule stops. Nothing errors.
Migrating a repeatable job to a scheduler
The mapping is clean. What was this:
// BullMQ v5 and earlier: removed in v6
await queue.add(
"nightly-report",
{ tenantId: 42 },
{ repeat: { pattern: "0 3 * * *", tz: "Europe/Amsterdam" } },
);
becomes this:
// BullMQ v6
await queue.upsertJobScheduler(
"nightly-report", // stable scheduler id
{ pattern: "0 3 * * *", tz: "Europe/Amsterdam" },
{ name: "nightly-report", data: { tenantId: 42 } },
);
The first argument is the scheduler id, the one thing you must keep stable across deploys. Reuse it and upsert updates the existing schedule. Derive it from a timestamp or a pod name and every rollout registers a new scheduler firing on its own. Tie the id to the job's identity, not the process that created it.
Removing the old repeatable entries
Code is not the whole migration. Repeatable definitions from v5 still sit in Redis, and the removal APIs that cleaned them up are gone. A v6 process pointed at a v5-populated Redis can be left with orphaned repeat keys that schedule nothing but clutter the keyspace. Recreate the schedules against a clean namespace, or migrate the keys deliberately.
Timezone handling moved to tz
The scheduler takes a tz option and honours DST for you. An omitted tz runs in the server's zone, so a container that moves region carries its schedule with it. Set tz explicitly on every scheduler that cares about wall-clock time.
Monitor the outcome, not the scheduler
Every failure here is invisible from inside BullMQ. A no-op repeat looks like a successful add. A stalled scheduler looks like a quiet queue. The built-in dashboard cannot flag a job that was never produced.
The check that survives all of it is external and success-based. Have the worker ping a monitor only after the real work finishes, and alert when the ping does not arrive on schedule:
const worker = new Worker("nightly-report", async (job) => {
await buildAndSendReport(job.data);
// Reached only on success; a thrown error skips the ping and BullMQ retries
await fetch("https://cronguard.app/api/ping/your-monitor-id");
});
If the migration dropped the schedule, if the queue is wedged, or if a bad scheduler id stopped the cadence, the ping stops arriving and you get paged. You do not have to work out which failure hit you, only notice that success went missing.
Frequently asked questions about BullMQ Job Schedulers
Does upgrading to BullMQ v6 break my existing repeatable jobs? Yes, if you still use the repeat option. BullMQ v6 removed the repeat option, the Repeat class, and the getRepeatableJobs, removeRepeatable, and removeRepeatableByKey methods. Code that still passes repeat compiles in JavaScript, but the option is ignored, so the job runs once and never repeats.
What replaces repeatable jobs in BullMQ v6? Job Schedulers, created with upsertJobScheduler. A scheduler holds a repeat rule and a job template, and produces jobs on cadence. Using upsert means redeploying the same scheduler id updates the existing schedule instead of creating a second one.
Why does my BullMQ scheduler stop producing jobs? A Job Scheduler keeps only one job in the delayed state and produces the next occurrence when the current one starts processing. If the previous job is stuck behind a backlog or left active by a crashed worker, the next run is never created and the schedule stalls silently.
Does the BullMQ cron pattern use the same syntax as crontab? Not exactly. BullMQ parses patterns with cron-parser, which accepts an optional seconds field as the first column. A five-field crontab expression works, but a six-field one is read with seconds first, so 0 15 3 * * * means 03:15:00 daily. Verify the next run time before trusting a copied expression.
How do I monitor recurring jobs in BullMQ? Watch the outcome from outside BullMQ, because its dashboard cannot show a job that was never produced. Have each worker report success only after the work completes, then alert when that report does not arrive on time. That single signal catches a schedule the migration dropped and one a stalled scheduler quietly stopped.
Further reading
- Durable Job Queues Are Replacing Cron: New Failure Modes
- Dead Man's Switch Monitoring: The Only Reliable Way to Watch Cron Jobs
- Why Cron Jobs Fail Silently (And How to Catch Them)
Conclusion: BullMQ v6 did not deprecate repeatable jobs, it removed them, and the Job Scheduler that replaces them only helps if you migrate deliberately. The upgrade stays quiet about the gap: a leftover repeat option becomes a no-op, a congested queue starves the scheduler, and a timestamped scheduler id multiplies your runs. None of it shows on the queue's own dashboard. Watch for the absence of a successful run from outside, the one signal that catches every version of this failure.
Sources: BullMQ changelog, BullMQ Job Schedulers guide.