Promoting a BullMQ job scheduler job skips the next run
Checked against BullMQ 6.3.4 on 4 October 2026 · All guides
A daily import runs at 06:40. One morning it failed upstream, the data was fixed by 10:00, and someone promoted the scheduler's delayed job to run the import again right away. It ran. The next morning, nothing ran at all.
Short answer. The delayed job of a job scheduler is its next run. job.promote() runs tomorrow's iteration today, and BullMQ schedules the one after it from that job's original time, so tomorrow is skipped. To run it now without losing a run, leave the scheduled job alone and queue.add a one-off copy.
What a job scheduler keeps in Redis
A scheduler created with queue.upsertJobScheduler(id, { pattern: "40 6 * * *" }, template) does not store a list of future runs. It keeps exactly one delayed job, with an id like repeat:daily-import:1791182400000: the next iteration, scheduled for its timestamp. Its repeatJobKey points back to the scheduler.
The iteration after it does not exist yet. A worker creates it when it picks this one up.
Why promoting skips a run
When a worker starts a job that belongs to a scheduler, it calls upsertJobScheduler again to create the next iteration, passing the job's own scheduled time as prevMillis (classes/worker.js). The scheduler then computes the next run from whichever is later, now or that time (classes/job-scheduler.js):
const prevMillis = opts.prevMillis || 0;
now = prevMillis < now ? now : prevMillis;
For a job that ran on time, both are the same moment and nothing is lost. For a promoted job, prevMillis is tomorrow at 06:40, which is later than now. So the next run is computed from tomorrow 06:40: the day after tomorrow. The iteration you promoted was tomorrow's, it already ran, and nothing replaces it.
The same happens with every schedules: promote an hourly job and the next hour is skipped.
Reproduce it
import { Queue, Worker } from "bullmq";
const queue = new Queue("imports", { connection });
await queue.upsertJobScheduler("daily-import", { pattern: "40 6 * * *" }, { name: "import" });
const [next] = await queue.getDelayed(); // repeat:daily-import:<tomorrow 06:40>
await next.promote(); // runs tomorrow's import now
new Worker("imports", async () => {}, { connection });
// After it runs, the scheduler's next job is the day AFTER tomorrow.
What to do instead
If you want an extra run now and the schedule untouched, do not move the scheduled job. Add a one-off copy with the same name and data:
const [scheduled] = await queue.getDelayed();
if (scheduled.repeatJobKey) {
// Drop the options that tie the job to the scheduler and to its time.
const { repeat, jobId, repeatJobKey, prevMillis, delay, timestamp, ...opts } = scheduled.opts;
await queue.add(scheduled.name, scheduled.data, opts); // runs now
// `scheduled` stays delayed: tomorrow 06:40 still runs.
}
Promoting is still the right call in one case: when you want to move the next run earlier, so it runs now instead of at its time. That is a decision, not a side effect, and it should be made knowingly.
How Bullpane handles it
We hit this in production with a daily import, which is how Bullpane learned to ask. Promoting a job that a scheduler produced opens a choice:
- Run a copy now: a one-off copy runs now and the scheduled run still happens. This is the default, for single and bulk promote alike.
- Promote and skip the next run: BullMQ's own promote. The scheduled run happens now instead, and the scheduler continues with the one after it.
Regular delayed jobs are promoted as before. Both choices are recorded in the audit log in Pro.
Run Bullpane on your queues Open the live demo
Sources
- BullMQ 6.3.4 source:
classes/worker.js(the worker callsupsertJobSchedulerwithoverride: falseand the job's id as producer) andclasses/job-scheduler.js(now = prevMillis < now ? now : prevMillis). - BullMQ docs: Job Schedulers
- The Bullpane fix (#3), with a regression test that reproduces BullMQ's behavior against a real Redis.
Something here wrong for your BullMQ version? Write to hello@bullpane.com and it gets fixed.