BullMQ Repeatable Jobs: Scheduling with Every and Cron
Most teams start by scheduling background work with a separate cron service - system crontab, node-cron, GitHub Actions - and only later realize BullMQ has a scheduler built in. That matters more than it sounds: when the scheduler and the queue are separate systems, you are monitoring two things, alerting on two things, and debugging mismatches between them. BullMQ repeatable jobs keep the schedule inside the queue itself, so one dashboard shows you both the schedule and every run - and they eliminate an entire class of "the cron fired but the job never ran" incidents. Used carelessly, though, they duplicate work and fire at the wrong times. This post explains how they work, when to use them, and what to watch out for.
What Are BullMQ Repeatable Jobs?
A repeatable job is a template: you add a job once with a repeat option, and BullMQ re-enqueues it on a fixed interval or a cron schedule until you remove it. Adding one is a single call:
import { Queue } from \"bullmq\";
const queue = new Queue(\"reports\", {
connection: { host: \"127.0.0.1\", port: 6379 },
});
// Every 60 seconds
await queue.add(\"heartbeat\", { service: \"billing\" }, {
repeat: { every: 60_000 },
});
// Daily at 03:00 Berlin time
await queue.add(\"nightly-export\", { tenant: \"acme\" }, {
repeat: { pattern: \"0 3 * * *\", tz: \"Europe/Berlin\" },
});
The two scheduling modes:
every: N- run every N milliseconds. Best for sub-minute cadences and simple intervals.pattern: \"m h dom mon dow\"- a standard 5-field cron expression (no seconds field). Best for anything that maps to wall-clock time.tz: \"Europe/Berlin\"- evaluate the cron against a named IANA timezone instead of the server's local time. If your schedule is customer-facing, always set this.startDate/endDate- bound the schedule.limit- cap the total number of runs (useful witheveryfor "run this 10 times, then stop").
How BullMQ Repeatable Jobs Work Under the Hood
Under the hood, a repeatable job is not one long-running job. BullMQ stores the schedule's next run timestamp in a Redis hash attached to the queue's meta key, and each time the schedule ticks it creates a normal delayed job for that run - exactly as if you had called queue.add(name, data, { delay }). The job only moves to the waiting list when its timestamp arrives, and a worker picks it up like any other job.
Because a schedule is identified by a repeat key - derived from the job name, the repeat options, and the timezone - adding the same repeatable job twice is an upsert, not a duplicate:
- Calling
queue.addagain with an identical repeat config updates the existing schedule; it does not create a second one. - Changing any part of the repeat config changes the repeat key, so it creates a new schedule alongside the old one. If you intend to change a schedule, remove the old one first.
- Each run is enqueued as its own job instance, so your completed history shows every single execution - useful for auditing, but it means completed counts grow on every tick.
Removing a schedule is the mirror image of adding it:
// Exact repeat options must match, or this silently no-ops
await queue.removeRepeatable(\"nightly-export\", {
pattern: \"0 3 * * *\",
tz: \"Europe/Berlin\",
});
// More reliable: use the key captured from the job itself
await queue.removeRepeatableByKey(job.opts.repeat.key);
The second form is safer in practice. Reconstructing the exact repeat object by hand is easy to get wrong, and a mismatched removeRepeatable fails silently - the job keeps running.
Repeatable Jobs vs a Separate Cron Service
Choosing between BullMQ's scheduler and an external cron runner is a real architecture decision:
- BullMQ repeatable jobs win when the work is queue work. The schedule lives next to the queue, the retry and backoff machinery applies to every run, and there is exactly one system to monitor.
- An external scheduler wins when you need cron features BullMQ does not support (seconds fields,
?characters, non-standard macros), or when the schedule must fire even if Redis is down. - Operationally, the repeatable approach means one fewer service, one fewer credential, and one fewer alert source. Teams that already run BullMQ rarely need a second scheduler.
A pragmatic middle ground: keep complex cron expressions in your external scheduler, but have it call queue.add(...) - the queue stays the source of truth for execution, and the scheduler is a thin trigger.
Common Pitfalls
- Timezone drift. Without
tz, the cron is evaluated in the server's local timezone. Deploy to a UTC box and your "daily at 8am" job quietly moves. Settzexplicitly. - Silent no-op removal.
removeRepeatablewith slightly different options (a missingtz, an extra space in the pattern) quietly does nothing. PreferremoveRepeatableByKeywith the key from the job'sopts.repeat. - Accidental schedule drift. Because the repeat key includes the full config, "fixing" a pattern in code creates a second schedule while the old one keeps running. Treat repeat config changes like migration changes: remove, then add.
- No seconds field. For sub-minute cadences, use
every, not a cron pattern with seconds - BullMQ's cron parser rejects it.
How to Observe BullMQ Repeatable Jobs
Repeatable jobs are invisible to most queue metrics. Between runs they do not sit in the waiting list - they live in the delayed set as timestamped entries - so a dashboard that only shows waiting, active, and completed counts gives you no signal about whether a schedule is healthy until a run fails. That is the gap a queue dashboard should close: see every repeatable job, its next run time, and its run history in one view, and alert when a scheduled run does not happen on time.
If you are running BullMQ in production, give scheduling the same visibility you would want for anything else in the queue. Start with the basics like stalled job detection and priority handling, and if you already use a limiter, check how it interacts with bursts of scheduled work in our rate limiting guide.
Summary
BullMQ repeatable jobs move scheduling into the queue itself, replacing a separate cron service with a repeat key, a timestamp, and the same retry machinery that runs your regular jobs. Use every for intervals, pattern with an explicit tz for wall-clock schedules, and removeRepeatableByKey to tear schedules down. The failure modes are quiet - duplicate schedules, wrong timezones, silent removal no-ops - so make sure you can see your schedules and their next run times before you need to.
Related Articles
BullMQ Worker Concurrency: How to Choose the Right Value
BullMQ worker concurrency decides how many jobs one worker runs at once — and the right value depends entirely on whether your jobs are I/O-bound or CPU-bound. Learn how the semaphore works, how to size it, and how to change it at runtime.
BullMQ Job Retention: removeOnComplete, removeOnFail, and Cleaning Up Redis
Without retention settings BullMQ keeps completed and failed jobs in Redis forever. Learn how job history is stored, how removeOnComplete and removeOnFail work, choosing retention per job class, and cleaning accumulated backlogs safely.
SQS Dead-Letter Queues: Redrive Policies, maxReceiveCount, and Safe Replay
SQS dead-letter queues catch messages that keep failing — but a misconfigured maxReceiveCount buries healthy ones. Learn how redrive policies work, how to design a DLQ worth monitoring, and how to replay messages without causing a second incident.