BullMQ Repeatable Jobs Duplicating After a Deployment Restart

BullMQ repeatable jobs duplicating after a deployment shows up as a job that fires exactly twice, reliably, specifically after every deploy that restarts your app — never during normal operation, only right after a redeploy. It's an easy bug to live with for longer than it should be, because the job itself isn't broken; there are just, quietly, two schedules now running it instead of one.
Short answer: you're very likely re-registering the repeatable schedule on every app boot using a pattern that creates a new schedule instead of updating the existing one — the fix is BullMQ's current upsertJobScheduler API with a genuinely stable scheduler ID, so restarting the app repeatedly updates the same schedule in place rather than accumulating duplicates.

Why Repeatable Jobs Duplicate on Every Deploy Restart
Repeatable job registration commonly lives in application bootstrap code — something that runs once when the app starts up, setting up the recurring schedule for a nightly digest, a daily report, or a periodic cleanup task. The problem is that "runs once when the app starts up" actually means "runs once every single time the app restarts," and a deploy that restarts your process runs that bootstrap code again. If the registration call creates a schedule rather than checking whether one already exists, every deploy since the app was last cleanly reset adds one more parallel schedule quietly firing the same job.
(If you've been checking your cron expression for a typo that would explain two firings — the pattern is probably fine. The duplication isn't in when the job runs. It's in how many separate schedules are running it.)
The Fix: upsertJobScheduler With a Stable ID
BullMQ's current documentation is direct about this: the older repeatable-jobs API (calling add() with repeat options) has been deprecated in favor of Job Schedulers specifically because the newer API's "upsert" semantics solve this exact problem:
1// bootstrap.ts — runs on every app start, safely, because upsertJobScheduler updates rather than duplicates
2import { Queue } from 'bullmq';
3
4const queue = new Queue('reports', { connection });
5
6await queue.upsertJobScheduler(
7 'nightly-digest', // stable, hardcoded ID — never generated from a timestamp or random value
8 { pattern: '0 0 3 * * *' }, // daily at 3:00 AM
9 {
10 name: 'send-nightly-digest',
11 data: { type: 'digest' },
12 opts: { attempts: 3 },
13 },
14);The critical detail is the first argument: a genuinely stable, hardcoded scheduler ID. Calling upsertJobScheduler with the same ID on every app boot updates the existing schedule in place rather than creating a new one — restart the app ten times with this exact code, and there's still exactly one nightly-digest schedule registered, not ten.

The Trap: An ID That Isn't Actually Stable
This fix only works if the ID itself never changes between deploys. It's an easy trap to fall into accidentally — building the scheduler ID from something that varies, like a deploy timestamp, a container instance ID, or a randomly generated UUID at boot time, defeats the entire purpose of upserting:
1// WRONG: this "stable" ID isn't actually stable — it's different on every restart
2const schedulerId = `nightly-digest-${Date.now()}`;
3await queue.upsertJobScheduler(schedulerId, { pattern: '0 0 3 * * *' }, jobTemplate);BullMQ has no way to recognize a new random ID as "the same schedule as before" — it correctly creates a new one, and you're back to the exact duplication problem this API is meant to solve, just with an extra layer of confusion about why "the fix" didn't fix anything.

Cleaning Up Schedules That Already Duplicated
Fixing the code going forward doesn't retroactively remove schedules that already accumulated in Redis from deploys before the fix. Clean those up explicitly:
1// cleanup.ts — remove stale schedulers left over from before the fix
2await queue.removeJobScheduler('nightly-digest-1706000000000');
3await queue.removeJobScheduler('nightly-digest-1706086400000');removeJobScheduler returns true if a scheduler with that ID existed and was removed, false otherwise — useful for confirming you've actually found and removed the stale ones rather than guessing at IDs that may not exist. BullMQ's own guide on managing job schedulers covers listing existing schedulers programmatically, which is worth doing before cleanup if you're not certain exactly how many stale ones accumulated. This BullMQ GitHub issue documents a real-world case of this exact duplication pattern if you want to compare your symptoms directly against someone else's.
The Opinion Part
Here's the position worth stating plainly, and it applies to any idempotency-adjacent bug in this genre: fixing the scheduling layer is necessary but not sufficient, and a job whose processor is also safe to run twice is a meaningfully more resilient system than one that merely stops creating duplicate schedules. Check whether today's digest was already sent before sending it again. Check whether the report for this period already generated before generating it a second time. That's not extra defensiveness for a problem that's already fixed — it's the same discipline covered in our API idempotency implementation guide, applied to scheduled jobs instead of webhook handlers or payment retries. A stable scheduler ID stops new duplicates from being created; an idempotent processor means the occasional duplicate that slips through anyway is invisible instead of a support ticket.
Conclusion
If a BullMQ repeatable job is firing twice after every deploy, check whether your schedule-registration code is creating a new schedule on every boot instead of updating one. Move to upsertJobScheduler with a genuinely stable, hardcoded scheduler ID, clean up any duplicate schedules that already accumulated with removeJobScheduler, and make the job's own processor idempotent as a second line of defense.
If BullMQ's Redis connection itself is also giving you trouble on the same project — jobs stuck waiting, or a MaxRetriesPerRequestError — our BullMQ jobs stuck waiting guide and our Upstash MaxRetriesPerRequestError guide both cover adjacent pieces of the same stack.
Give the schedule a name that never changes, and watch the digest that used to arrive twice settle down to arriving exactly once — which was the whole point of scheduling it in the first place.
Frequently Asked Questions
The most common cause is calling code that registers a repeatable schedule every time the app boots, without first checking whether that schedule already exists. If the registration call creates a new schedule rather than updating an existing one, every deploy that restarts your app adds another parallel schedule running the same job, and you end up with as many duplicate firings as you have deploys since the schedule was last cleaned up.
It's the current recommended API for repeatable jobs, replacing the older add()-with-repeat-options pattern. Calling upsertJobScheduler with the same stable scheduler ID on every app boot updates the existing schedule in place rather than creating a new one — the 'upsert' behavior means restarting your app repeatedly with the same ID is safe by design, instead of quietly accumulating duplicate schedules.
If the ID passed to upsertJobScheduler changes between deploys — commonly because it's accidentally built from something that varies, like a timestamp or a randomly generated value — BullMQ has no way to recognize it as the same schedule, and creates a new one every time instead of updating the existing one, producing exactly the duplication this fix is meant to prevent.
Use queue.removeJobScheduler(schedulerId) for each stale scheduler ID still registered from before the fix, or list existing schedulers and remove any that don't match your current, stable ID convention. Fixing the code going forward doesn't retroactively remove schedules that already exist in Redis from previous deploys — those need explicit cleanup.
Yes, as defense in depth. A stable scheduler ID prevents new duplicate schedules from being created, but it doesn't guarantee against every possible edge case around restart timing. Making the job itself safe to run twice — checking whether today's digest was already sent before sending it again, for instance — means an occasional duplicate firing is harmless instead of user-visible.
