BullMQ Job Scheduling: Complete Guide to Repeatable Jobs, Delays, and Cron Patterns
BullMQ Job Scheduling: The Complete Guide to Repeatable Jobs, Delays, and Cron Patterns
Job scheduling is one of the most powerful features in BullMQ. Whether you need a background job that runs every 5 minutes, an email that sends 24 hours after signup, or a report that generates at 9 AM every weekday, BullMQ provides production-grade primitives for all of it. This guide covers everything — from legacy repeatable jobs to the modern Job Scheduler API (v5.16+), delayed jobs, cron patterns, and how to monitor and manage scheduled jobs at scale.
By the end of this article, you'll understand the BullMQ job scheduling ecosystem thoroughly and be able to choose the right scheduling strategy for any use case.
Understanding BullMQ's Job Scheduling Landscape
What Are Scheduled Jobs in BullMQ?
Scheduled jobs are jobs that execute at a future time or on a recurring basis. BullMQ supports three scheduling mechanisms:
- Delayed jobs — one-time, execute after N milliseconds
- Repeatable jobs (legacy) — recurring via
queue.add()withrepeatoption - Job Schedulers (modern, v5.16+) — recurring via
queue.upsertJobScheduler()
All scheduled jobs leverage Redis sorted sets under the hood, specifically the delayed set, which stores jobs ordered by their next execution timestamp. BullMQ's Worker periodically checks this set and promotes eligible jobs to the waiting list.
Legacy vs. Modern API: Deprecation Roadmap
BullMQ has undergone significant API evolution. Understanding the deprecation timeline is critical for maintaining healthy codebases:
QueueSchedulerclass: deprecated since BullMQ v2.0 (its functionality moved into the Worker)- Previously needed for delayed job promotion and stalled job detection
- Still present in many old tutorials — important to call out so developers don't add it to new projects
repeatoption onqueue.add(): deprecated since v5.16.0- Replaced by
upsertJobScheduler()with a dedicated Job Scheduler - Old code still works but will emit deprecation warnings in newer versions
- Replaced by
- Migration path: Move from
queue.add(name, data, { repeat })→queue.upsertJobScheduler(id, repeatOpts, template)
Code example — Legacy (deprecated):
import { Queue, QueueScheduler } from 'bullmq';
const connection = { host: 'localhost', port: 6379 };
const myQueueScheduler = new QueueScheduler('Paint', { connection });
const myQueue = new Queue('Paint', { connection });
// Deprecated in v5.16+ — use upsertJobScheduler instead
await myQueue.add(
'submarine',
{ color: 'yellow' },
{
repeat: {
pattern: '0 15 3 * * *', // daily at 3:15 AM
},
},
);
Code example — Modern (recommended):
import { Queue } from 'bullmq';
const connection = { host: 'localhost', port: 6379 };
const myQueue = new Queue('Paint', { connection });
// Recommended: Job Scheduler API (v5.16+)
const firstJob = await myQueue.upsertJobScheduler(
'morning-report',
{ pattern: '0 15 3 * * *' },
{
name: 'generate-report',
data: { type: 'daily', format: 'pdf' },
opts: { attempts: 3, backoff: { type: 'exponential', delay: 2000 } },
},
);
The modern API is cleaner, more explicit, and separates scheduling concerns from job production.
Delayed Jobs — One-Time Future Execution
Delayed jobs are the simplest form of scheduled jobs in BullMQ. They execute exactly once, after a specified delay.
Adding a Delayed Job
Use the delay option (in milliseconds) with queue.add(). The job moves from the "delayed" set to the "waiting" list once the delay expires. Note that processing is subject to worker availability — the job is not guaranteed to execute at the exact millisecond.
import { Queue } from 'bullmq';
const myQueue = new Queue('notifications');
// Process this job at least 5 seconds from now
await myQueue.add('send-reminder', { userId: 123 }, { delay: 5000 });
Scheduling for an Absolute Future Time
For absolute timestamps, calculate the difference between the target date and now:
const targetTime = new Date('2026-07-15T10:30:00Z');
const delay = Number(targetTime) - Date.now();
await myQueue.add(
'welcome-email',
{ userId: 42, email: 'user@example.com' },
{ delay },
);
Changing a Delayed Job's Delay
You can reschedule an existing delayed job using job.changeDelay(). This only works on jobs currently in the "delayed" state — once a job is moved to "waiting" or "active", the delay cannot be changed.
import { Job } from 'bullmq';
// Create a delayed job
const job = await Job.create(
myQueue,
'reminder',
{ appointmentId: 101 },
{ delay: 3600000 }, // 1 hour
);
// Reschedule it to 4 hours from now instead
await job.changeDelay(14400000); // 4 hours
Common Use Cases for Delayed Jobs
- Email/SMS reminders — "24 hours before appointment" reminders
- Scheduled notifications — push notifications timed to user activity
- Webhook retries with backoff — progressively delayed retry attempts
- One-time data syncs — schedule a sync for off-peak hours
- Trial expiration handling — flag accounts after a trial period
Repeatable Jobs with the Job Scheduler API
The Job Scheduler API (introduced in BullMQ v5.16.0) is the modern, recommended way to create recurring jobs.
What is a Job Scheduler?
A Job Scheduler acts as a factory that produces jobs on a schedule. Key characteristics:
- Uses
upsertJobScheduler()— upsert semantics prevent duplicate schedulers - Always exactly one delayed job associated with an active scheduler at any time
- The next job is added when the current one starts processing (not when it finishes)
- Safe to call repeatedly — ideal for idempotent deployment scripts
The upsertJobScheduler() Method
The method takes three parameters:
schedulerId— unique identifier for the schedulerrepeatOpts—every,pattern,startDate,endDate,limit,immediatelytemplate(optional) —name,data,optsfor generated jobs
Returns the first delayed job instance.
Basic every-interval scheduler:
const firstJob = await queue.upsertJobScheduler(
'heartbeat',
{ every: 30000 }, // every 30 seconds
{
name: 'ping',
data: { service: 'api-gateway' },
},
);
Scheduler with job template options:
const firstJob = await queue.upsertJobScheduler(
'data-export',
{ pattern: '0 0 2 * * *' }, // daily at 2 AM
{
name: 'export',
data: { format: 'csv' },
opts: {
attempts: 5,
backoff: { type: 'exponential', delay: 5000 },
removeOnComplete: 100,
removeOnFail: 500,
},
},
);
Key Behaviors of Job Schedulers
- Upsert, not add: Safe to call repeatedly in deployment — updates an existing scheduler rather than creating a duplicate
- No accumulation: If workers are offline, missed iterations are NOT queued up. Only the next scheduled iteration runs when workers come back
- Clock-aligned intervals:
every: 2000fires at even seconds (0, 2, 4…) not relative to the time it was added - Immediate first run: From v5.19.0, the first repetition always runs immediately for new schedulers
Cron Patterns in BullMQ
Cron patterns provide a powerful, expressive way to define complex recurring schedules.
Standard 5/6-Field Cron Syntax
BullMQ uses cron-parser which supports standard UNIX cron with an optional seconds field:
* * * * * *
┬ ┬ ┬ ┬ ┬ ┬
│ │ │ │ │ └ day of week (0-7, 1L-7L; 0/7 = Sun)
│ │ │ │ └───── month (1-12)
│ │ │ └────────── day of month (1-31, L = last)
│ │ └─────────────── hour (0-23)
│ └──────────────────── minute (0-59)
└───────────────────────── second (0-59, optional)
Common Cron Patterns Cheat Sheet
| Intended Schedule | 6-Field Pattern | 5-Field Pattern |
|---|---|---|
| Every minute | * * * * * * |
* * * * * |
| Every 5 minutes | 0 */5 * * * * |
*/5 * * * * |
| Every hour | 0 0 * * * * |
0 * * * * |
| Daily at midnight | 0 0 0 * * * |
0 0 * * * |
| Daily at 9 AM | 0 0 9 * * * |
0 9 * * * |
| Every Monday at 8 AM | 0 0 8 * * 1 |
0 8 * * 1 |
| Weekdays at 6 PM | 0 0 18 * * 1-5 |
0 18 * * 1-5 |
| 1st of month at noon | 0 0 12 1 * * |
0 12 1 * * |
| Every 15 minutes | 0 */15 * * * * |
*/15 * * * * |
Cron with Timezone Support
BullMQ supports timezone-aware scheduling via the tz option with IANA timezone names:
await queue.upsertJobScheduler(
'business-hours-job',
{
pattern: '0 0 9 * * 1-5', // Mon-Fri at 9 AM
tz: 'America/New_York', // timezone-aware
},
{
name: 'morning-task',
data: { shift: 'east-coast' },
},
);
Named Cron Expressions
BullMQ's cron-parser supports named expressions like @hourly, @daily, @weekly, @monthly, and @yearly for improved readability:
await queue.upsertJobScheduler(
'weekly-cleanup',
{ pattern: '@weekly' },
{ name: 'cleanup', data: { olderThanDays: 30 } },
);
Repeat Options — Controlling Schedule Behavior
The repeatOpts parameter gives you fine-grained control over scheduling behavior.
every — Fixed Interval (Milliseconds)
The simplest form of recurrence. Intervals are clock-aligned — every: 60000 fires at the start of each minute, not relative to when the scheduler was created.
await queue.upsertJobScheduler(
'poll-db',
{ every: 5000, limit: 1000 }, // every 5s, max 1000 runs
{ name: 'poll', data: { query: 'SELECT ...' } },
);
pattern — Cron Expression
More expressive than every for complex schedules. Supports tz for timezone-aware scheduling and handles DST transitions correctly.
startDate and endDate
Control the active window for a scheduler:
await queue.upsertJobScheduler(
'seasonal-job',
{
every: 86400000, // daily
startDate: new Date('2026-06-01T00:00:00Z'),
endDate: new Date('2026-08-31T23:59:59Z'),
},
{ name: 'seasonal-task', data: { season: 'summer' } },
);
limit — Maximum Repetitions
Stop producing jobs after N iterations. The scheduler is not automatically removed after reaching the limit — use getJobSchedulers() to verify its status.
await queue.upsertJobScheduler(
'onboarding-emails',
{
every: 86400000,
limit: 7, // send 7 daily emails, then stop
},
{ name: 'email-sequence', data: { templateId: 42 } },
);
immediately (Deprecated in v5.19.0)
Previously forced the first run to happen immediately. In v5.19.0+, all new schedulers behave as if immediately: true. Existing schedulers on upsert follow the normal interval. No action needed in modern code.
Managing and Removing Scheduled Jobs
BullMQ provides a full set of management APIs for inspecting and removing schedulers.
Listing All Job Schedulers
getJobSchedulers(offset, limit, asc) returns paginated scheduler metadata ordered by next execution time:
const schedulers = await queue.getJobSchedulers(0, 99, true);
console.log('Active schedulers:', schedulers);
// [
// { key: '...', id: 'morning-report', pattern: '0 15 3 * * *', ... },
// { key: '...', id: 'heartbeat', every: 30000, ... },
// ]
Getting a Specific Scheduler
const scheduler = await queue.getJobScheduler('morning-report');
console.log('Scheduler details:', scheduler);
Removing a Single Scheduler
const removed = await queue.removeJobScheduler('expired-task');
// Returns true if found and removed, false otherwise
Legacy Repeatable Job Management (Pre-v5.16)
If you are still using the old API, these methods are available:
// List all repeatable jobs
const repeatableJobs = await queue.getRepeatableJobs();
// Remove by name + repeat options
await queue.removeRepeatable('bird', { every: 10000, limit: 100 });
// Remove by repeat key
const job = await queue.add('bird', {}, {
repeat: { every: 10000, limit: 100 },
jobId: 'colibri',
});
await queue.removeRepeatableByKey(job.repeatJobKey);
Job ID Behavior in Repeatable Jobs
The jobId in repeatable jobs does NOT serve as the final job ID — repeatable jobs need unique IDs per iteration. Instead, jobId differentiates two repeatable jobs with the same name but different semantics:
// Two distinct repeatable jobs, same name, same interval, differentiated by jobId
await queue.add('bird', { type: 'colibri' }, {
repeat: { every: 10000, limit: 100 },
jobId: 'colibri',
});
await queue.add('bird', { type: 'pigeon' }, {
repeat: { every: 10000, limit: 100 },
jobId: 'pigeon',
});
Custom Repeat Strategies
For advanced use cases that standard cron patterns cannot express, BullMQ supports custom repeat strategies.
When to Use a Custom Strategy
- Non-standard schedules — RRULE expressions, business-day calendars
- Dynamic intervals — intervals that depend on job data or external conditions
- Integration with external scheduling systems — bridge between BullMQ and an external scheduler
Implementing a Custom Strategy
The repeatStrategy function must be provided to both Queue and Worker:
import { Queue, Worker } from 'bullmq';
import { rrulestr } from 'rrule';
const strategy = {
repeatStrategy: (millis: number, opts: any, _jobName?: string) => {
const currentDate =
opts.startDate && new Date(opts.startDate) > new Date(millis)
? new Date(opts.startDate)
: new Date(millis);
const rrule = rrulestr(opts.pattern);
const next = rrule.after(currentDate, false);
return next?.getTime();
},
};
const myQueue = new Queue('paint', { settings: strategy });
await myQueue.upsertJobScheduler(
'collibris',
{ pattern: 'RRULE:FREQ=SECONDLY;INTERVAL=10' },
{ data: { color: 'green' } },
);
const worker = new Worker(
'paint',
async (job) => { /* process */ },
{ settings: strategy }, // MUST also pass strategy to Worker
);
Monitoring and Observability for Scheduled Jobs
Monitoring is crucial for maintaining healthy scheduled job infrastructure.
Why Monitoring Matters
- Schedulers silently fail — if no workers are running, the scheduler still exists but no jobs are processed
- Accumulated delayed jobs are invisible in simple queue length checks
- Visibility gaps — you need to know which schedulers exist, when they next fire, and how many runs have occurred
BullMQ Built-in Getters for Observability
BullMQ provides several built-in methods for inspecting queue state:
// Get job counts across all statuses
const counts = await queue.getJobCounts(
'wait', 'active', 'completed', 'failed', 'delayed', 'repeat',
);
// { wait: 12, active: 3, completed: 1050, failed: 2, delayed: 8, repeat: 4 }
// Get delayed jobs (paginated)
const delayedJobs = await queue.getJobs(['delayed'], 0, 50);
// Get job schedulers
const schedulers = await queue.getJobSchedulers(0, -1, true);
Visualizing Scheduled Jobs with QueueHub
QueueHub (Queue Hub) provides a SaaS dashboard to monitor and manage all scheduled jobs:
- View all active Job Schedulers in a table — see pattern, interval, and next run time
- List delayed jobs with count badges
- Manually trigger, delay, or remove individual scheduled jobs
- Redis connection support: local, TLS, Valkey, AWS ElastiCache
- Agent tunneling for private/on-premise Redis instances
- Multi-org team collaboration for queue management
With QueueHub, you can see exactly which schedulers exist, when they fire, and how many iterations remain — all without writing CLI commands or querying Redis directly.
Observability Best Practices
- Regularly poll
getJobSchedulers()to verify expected schedulers exist - Monitor
getJobCounts('delayed')as a health metric — a sudden spike may indicate scheduler issues - Set up alerts for schedulers that haven't produced a job in N hours
- Use Worker event listeners for
'completed','failed', and'drained'events - Log scheduler upserts and removals in deployment pipelines
- Use a dashboard like QueueHub for at-a-glance visibility across all queues
Complete End-to-End Example
Here's a complete example tying together Job Schedulers, delayed jobs, and a Worker:
import { Queue, Worker, Job } from 'bullmq';
const connection = { host: 'localhost', port: 6379 };
// 1. Define queue
const emailQueue = new Queue('email-notifications', { connection });
// 2. Create a daily report scheduler for weekdays at 8 AM Chicago time
await emailQueue.upsertJobScheduler(
'daily-digest',
{ pattern: '0 0 8 * * 1-5', tz: 'America/Chicago' },
{
name: 'send-digest',
data: { type: 'daily-digest', userIds: [] },
opts: { attempts: 3 },
},
);
// 3. Add a one-time delayed welcome email (1 hour after signup)
await emailQueue.add(
'welcome-email',
{ userId: 42, email: 'newuser@example.com' },
{ delay: 3600000 },
);
// 4. Create a worker that handles both job types
const worker = new Worker(
'email-notifications',
async (job: Job) => {
switch (job.name) {
case 'send-digest':
console.log(`Generating daily digest...`);
// ... generate and send
break;
case 'welcome-email':
console.log(`Sending welcome to ${job.data.email}`);
// ... send email
break;
}
},
{ connection },
);
// 5. Graceful shutdown
process.on('SIGTERM', async () => {
await worker.close();
await emailQueue.close();
});
Frequently Asked Questions
Q: Does the Worker need a QueueScheduler instance? A: No. QueueScheduler is deprecated since BullMQ v2.0. The Worker handles delayed job promotion and stalled job detection internally.
Q: What happens if a repeatable job's processing time exceeds the interval? A: The next job is scheduled when the current one starts processing. With one slow worker, jobs queue up. With enough workers or concurrency, the desired frequency can be maintained.
Q: Do missed repeatable jobs accumulate if no workers are running? A: No. BullMQ does not queue up missed iterations. When workers come back online, only the next scheduled iteration runs.
Q: Can I have two Job Schedulers with the same pattern?
A: Yes — each needs a unique schedulerId. They are fully independent and can share the same pattern.
Q: Is BullMQ's cron timezone-aware?
A: Yes — use the tz option with IANA timezone names (e.g., 'America/New_York', 'Europe/London'). BullMQ handles DST transitions correctly.
Q: Can I dynamically update a scheduler's interval?
A: Yes — call upsertJobScheduler() with the same schedulerId and new options. It upserts (updates or creates) the scheduler.
Q: What is the minimum practical interval for every?
A: While BullMQ supports millisecond intervals, consider your Redis latency and worker throughput. Intervals below 1000ms should be used with caution and testing.
Conclusion
BullMQ's job scheduling ecosystem is powerful and flexible. Use the modern Job Scheduler API (upsertJobScheduler) for all new projects, delayed jobs for one-off future execution, and cron patterns for complex recurring schedules. Avoid the deprecated QueueScheduler class and legacy repeat option on queue.add().
With the right monitoring — whether via BullMQ's built-in getters or a dedicated dashboard — you can maintain full visibility into your scheduled job infrastructure and catch issues before they become production incidents.
Ready to take control of your BullMQ queues? QueueHub (Queue Hub) gives you a visual dashboard for all your scheduled, delayed, and active jobs. Monitor schedulers, inspect delayed jobs, and manage queues across Redis backends — all without writing CLI commands. Start your free trial today.
Article saved on: 2026-06-30 BullMQ version referenced: 5.x (Job Scheduler API introduced in v5.16.0)
Related Resources
Related Articles
Best BullMQ Dashboard Alternatives in 2026: A Comprehensive Comparison
Comparing every BullMQ UI option side by side: Bull Board, Arena, Taskforce, QueueHub, and raw redis-cli. Feature matrices, pricing, pros and cons, and recommendations for every team size.
QueueHub vs pg-boss: Redis vs PostgreSQL as a Job Queue Backend
pg-boss uses PostgreSQL as a Node.js job queue, while BullMQ (monitored by QueueHub) uses Redis. We compare the two approaches across throughput, persistence, transactional queues, deployment, and when transactional enqueueing matters.
QueueHub vs Temporal: Job Queues vs Workflow Orchestration
Temporal is a workflow orchestration platform — a different category from Redis-backed job queues. We compare Temporal.io against BullMQ + QueueHub, covering complexity, use cases, durability, observability, and when to choose each.