BullMQ Job Scheduling: Repeatable Jobs, Delays, Cron Patterns & the Scheduler System
Every backend application eventually needs something to run on a schedule — nightly database cleanups, hourly report generation, weekly email digests, or a heartbeat check every 30 seconds. BullMQ handles all of these through its scheduling system, and it does so without a background daemon or external cron service.
In this post, we'll cover BullMQ's three scheduling primitives — delayed jobs, interval-based repetition, and cron-pattern scheduling — along with the modern Job Scheduler API (introduced in v5.16.0) that supersedes the legacy repeatable job approach. You'll learn how scheduling works under the hood, how to manage schedules dynamically at runtime, and what production patterns keep your scheduled jobs reliable.
Prerequisite knowledge: You should be familiar with basic BullMQ Queue and Worker setup. If you need a refresher, check out our guide on setting up BullMQ workers.
The Three Scheduling Primitives
BullMQ gives you three distinct ways to schedule jobs, each suited to different use cases.
One-Time Delayed Jobs
The simplest scheduling primitive is the delay option on queue.add(). It tells BullMQ to wait N milliseconds before making the job available to workers.
import { Queue } from 'bullmq';
import IORedis from 'ioredis';
const connection = new IORedis({ host: 'localhost', port: 6379 });
const queue = new Queue('notifications', { connection });
// Send a follow-up email 24 hours after signup
await queue.add('follow-up', { userId: 'abc' }, {
delay: 24 * 60 * 60 * 1000, // 24 hours in milliseconds
});
You can also schedule a job at an absolute future time by calculating the delay from a Date object:
// Schedule a Christmas morning job
const target = new Date('2026-12-25T10:00:00Z');
await queue.add('christmas-job', { gift: 'delivered' }, {
delay: target.getTime() - Date.now(),
});
Key limitation: Delayed jobs aren't guaranteed exact-second precision. The actual execution time depends on worker availability, concurrency settings, and overall queue backlog. For most use cases, a few seconds of variance is perfectly acceptable.
Interval-Based Repetition (every)
When you need a job to run on a fixed interval, use the repeat.every option:
// Ping a health endpoint every 30 seconds, up to 1000 times
await queue.add('heartbeat', { service: 'api' }, {
repeat: {
every: 30000,
limit: 1000,
},
});
The every value is in milliseconds, not seconds — a common footgun. every: 5000 means every 5 seconds, not 5 seconds of something else. Set limit to cap the total number of repetitions, and startDate / endDate to bound the schedule by time.
Cron Pattern Scheduling
For complex time-based schedules, use standard cron expressions:
await queue.add('daily-report', { type: 'sales' }, {
repeat: { pattern: '0 9 * * *' }, // Every day at 9:00 AM
});
BullMQ supports standard 5-field cron (minute hour day-of-month month day-of-week) and an optional 6th field for seconds (second minute hour day-of-month month day-of-week).
Here are common cron patterns:
| Purpose | Pattern |
|---|---|
| Every minute | * * * * * |
| Every hour at :00 | 0 * * * * |
| Daily at midnight | 0 0 * * * |
| Every Monday at 9 AM | 0 9 * * 1 |
| 1st of month at 6 AM | 0 6 1 * * |
| Every 15 minutes | */15 * * * * |
| Business hours, weekdays | 0 9-17 * * 1-5 |
Using the optional seconds field, you can be more precise:
// Run exactly at the 0-second mark of every 5th minute
await queue.add('precise-task', {}, {
repeat: { pattern: '0 */5 * * * *' }, // seconds:0, every 5 minutes
});
How BullMQ Scheduling Actually Works
Understanding the internals of BullMQ scheduling helps you design better, more predictable systems.
No Background Scheduler Daemon
A common misconception is that BullMQ runs a persistent scheduler agent. It does not. There is no separate "cron daemon" process. Repeatable jobs are implemented as delayed jobs that re-add themselves upon execution. The entire system is driven by the same delayed-job mechanism that powers one-time scheduled work.
The Lifecycle of a Repeatable Job
Here's what happens step by step when you call queue.add() with a repeat option:
- Registration: BullMQ creates a metadata entry in Redis, keyed by the job name combined with the repeat options. This entry records the schedule config.
- First scheduling: BullMQ calculates the next occurrence (aligned to the cron boundary) and places a single delayed job in the queue.
- Execution: A worker picks up the delayed job when its time arrives and processes it.
- Chaining: When the job completes, BullMQ evaluates the repeat configuration and places the next occurrence as a new delayed job.
- Repeat: Steps 3–4 continue indefinitely, or until
limit/endDateis reached.
Here's a visual representation of that flow:
queue.add({ repeat: { pattern } })
│
▼
┌─────────────────────────────┐
│ Redis: Repeatable Metadata │
│ (job name, pattern, tz) │
└──────────┬──────────────────┘
│
▼
┌─────────────────────────────┐
│ Redis: Delayed Set │ ← first occurrence
│ (next scheduled time) │
└──────────┬──────────────────┘
│ (time passes)
▼
┌─────────────────────────────┐
│ Worker picks up & executes │
└──────────┬──────────────────┘
│ (on complete)
▼
┌─────────────────────────────┐
│ Next occurrence calculated │
│ → new delayed job enqueued │
└─────────────────────────────┘
Important Properties of This System
- No missed-job accumulation: If all workers are down, only one delayed job waits in the queue. No backlog builds up. When workers come back online, they process that single job and the schedule continues from there.
- Deduplication: Identical repeat configurations (same name + pattern + jobId) produce the same Redis key. Duplicate
queue.add()calls are idempotent — the second call simply updates the metadata. - Rate follows processing: The actual job cadence is limited by how fast workers can pick up and process jobs. If processing takes 5 seconds and the interval is 1 second, the effective rate approximates 5 seconds per job (or faster with higher
concurrency). - Always one delayed job: As long as a scheduler is active, exactly one associated job exists in "delayed" status in the queue at any time.
Legacy API: Repeatable Jobs (Deprecated in v5.16.0+)
Before the Job Scheduler API arrived, all scheduling went through queue.add() with a repeat option. This is still fully supported, but the Job Scheduler API is now the recommended path.
Adding Repeatable Jobs via queue.add()
const job = await queue.add('report', { type: 'weekly' }, {
repeat: { pattern: '0 9 * * 1' }, // Every Monday at 9 AM
});
console.log(job.repeatJobKey); // e.g. "report:0 9 * * 1:==="
Managing Repeatable Jobs
List all registered repeatable configs:
const repeatableJobs = await queue.getRepeatableJobs();
// Returns array of { key, name, pattern, every, tz, next, ... }
Remove by name and exact options:
await queue.removeRepeatable('report', { pattern: '0 9 * * 1' });
Or remove by the unique repeat job key:
const job = await queue.add('report', data, { repeat: { pattern: '0 9 * * *' } });
await queue.removeRepeatableByKey(job.repeatJobKey);
The Migration Path
The legacy API works fine, but it has limitations: there's no built-in way to update a schedule (you must remove and re-add), and you can't attach job templates (name, data, opts) that all produced jobs inherit. The Job Scheduler API solves both problems with an idempotent upsert operation. If you're starting a new project today, use Job Schedulers. If you have existing repeatable jobs, they'll continue working — but consider migrating when you next change a schedule.
Modern API: Job Schedulers (v5.16.0+)
Introduced in BullMQ v5.16.0, the Job Scheduler API provides a first-class abstraction for managing scheduled job factories.
What is a Job Scheduler?
A Job Scheduler is a factory that produces jobs according to repeat settings. It's managed via queue.upsertJobScheduler(id, repeatOpts, template?). The "upsert" semantics are critical: create or update — never duplicates. This makes it safe to call repeatedly in production during application startup, configuration reloads, or dynamic schedule changes.
Creating a Job Scheduler
// Interval-based: every 10 seconds
await queue.upsertJobScheduler(
'heartbeat-scheduler',
{ every: 10000 },
{ name: 'heartbeat', data: { source: 'worker-1' } }
);
// Cron-based: daily at 9 AM, New York timezone
await queue.upsertJobScheduler(
'daily-report-scheduler',
{ pattern: '0 9 * * *', tz: 'America/New_York' },
{ name: 'daily-report', data: { type: 'sales' } }
);
Job Templates
The third argument to upsertJobScheduler is an optional template that all produced jobs inherit. You can set name, data, and opts (backoff, attempts, removeOnComplete, etc.):
await queue.upsertJobScheduler('cleanup-scheduler', { every: 3600000 }, {
name: 'cleanup',
data: { target: 'temp-files' },
opts: {
attempts: 3,
backoff: { type: 'exponential', delay: 2000 },
removeOnComplete: { age: 86400 }, // Keep for 24 hours
},
});
To update the template, call upsertJobScheduler again with the same scheduler ID — the template is atomically replaced.
Listing, Retrieving, and Removing Schedulers
// List all schedulers (with pagination and optional count)
const schedulers = await queue.getJobSchedulers(0, 99, true);
// Returns array of { key, name, pattern, every, tz, next, endDate, ... }
// Get a single scheduler by ID
const scheduler = await queue.getJobScheduler('daily-report-scheduler');
// Remove a scheduler
const removed = await queue.removeJobScheduler('daily-report-scheduler');
// Returns true if found and removed, false if not found
Practical: Replacing a Schedule
One of the biggest advantages of Job Schedulers is the ability to atomically replace a schedule:
// Update from every-10s to every-30s
await queue.upsertJobScheduler(
'heartbeat-scheduler',
{ every: 30000 }, // new interval
{ name: 'heartbeat', data: { source: 'worker-1' } }
);
// Old schedule is atomically replaced — no stale delayed jobs, no race conditions
Advanced Scheduling Features
BullMQ's scheduling system includes several advanced features that handle real-world scheduling complexities.
Timezone-Aware Cron
BullMQ supports IANA timezone names via the tz option. This is essential for business-hour schedules and DST transitions:
// Run at 8 AM Eastern, DST-aware
await queue.upsertJobScheduler('nyc-market-open', {
pattern: '0 8 * * 1-5',
tz: 'America/New_York',
}, { name: 'market-open' });
Supported timezone formats include America/New_York, Europe/London, Asia/Tokyo, Australia/Sydney, and any other IANA-compliant timezone string. BullMQ handles DST transitions correctly — no missed or double runs during clock changes.
Start Date and End Date
Bound a schedule's active window with startDate and endDate:
// Send a daily promotional email only in December 2026
await queue.upsertJobScheduler('promotion-email', {
pattern: '0 10 * * *',
startDate: new Date('2026-12-01'),
endDate: new Date('2026-12-31'),
tz: 'America/New_York',
}, { name: 'daily-promo' });
startDate: The first occurrence will be the next valid time after this date.endDate: No new jobs will be created after this date. Already-enqueued delayed jobs still execute.
Limit: Maximum Repetitions
The limit option provides an atomic end condition:
// Send exactly 10 heartbeat pings, one per hour
await queue.upsertJobScheduler('limited-ping', {
every: 3600000,
limit: 10,
}, { name: 'ping' });
Job ID and Scheduler Implications
Job Schedulers auto-generate job IDs to prevent duplicate job creation. You cannot set a custom jobId for scheduler-produced jobs. If you need to discriminate between scheduler-produced jobs and manually enqueued ones, use the name field rather than relying on IDs.
Custom Repeat Strategies
While the built-in every and cron strategies cover roughly 95% of use cases, sometimes you need something more flexible — RRULE (iCalendar) patterns, irregular intervals, or business-day calendars.
How Custom Strategies Work
Provide a repeatStrategy function in the settings object on both the Queue and the Worker:
interface RepeatOptions {
pattern?: string;
every?: number;
limit?: number;
startDate?: Date | number;
endDate?: Date | number;
tz?: string;
}
type RepeatStrategy = (
millis: number,
opts: RepeatOptions,
jobName?: string
) => number | undefined;
The function receives the current timestamp (millis) and the RepeatOptions from the scheduler. Return the next occurrence timestamp (in milliseconds since epoch) or undefined to stop scheduling.
Example: RRULE Strategy
import { rrulestr } from 'rrule';
const settings = {
repeatStrategy: (millis: number, opts: RepeatOptions) => {
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 queue = new Queue('notifications', { connection, settings });
const worker = new Worker('notifications', processor, { connection, settings });
// Use RRULE pattern instead of cron
await queue.upsertJobScheduler('custom-bird', {
pattern: 'RRULE:FREQ=SECONDLY;INTERVAL=10;WKST=MO',
}, { data: { color: 'green' } });
Key Constraint
Only ONE repeatStrategy function can be set per queue. If you need to handle multiple pattern types, inspect opts.pattern inside the function and branch accordingly.
Managing Delayed Jobs
Whether you're using one-time delays or scheduler-produced jobs, you'll occasionally need to inspect or manipulate delayed jobs directly.
Changing Delay Post-Enqueue
const job = await Job.create(queue, 'test', { foo: 'bar' }, { delay: 5000 });
// Reschedule — now runs 10 seconds from now, not 5
await job.changeDelay(10000);
This only works on jobs currently in the "delayed" state. Calling changeDelay on an active or completed job has no effect.
Promoting Delayed Jobs
Move a delayed job to the "waiting" queue for immediate processing:
if (job.status === 'delayed') {
await job.promote();
}
This is useful when you need to expedite a scheduled task without removing and re-adding the job.
Finding Delayed Jobs
const delayedJobs = await queue.getDelayed();
// Returns all jobs currently in the delayed set
Pitfalls and Production Notes
- Delayed jobs are stored in a Redis sorted set. Very large sets (tens of thousands of delayed jobs) can impact
zrangeperformance. Keep your delayed job count reasonable. changeDelayonly works on delayed-state jobs — you cannot reschedule already-active jobs.- If you're using Queue Hub, delayed jobs are shown with a "Promote" action button for one-click expediting.
Production Patterns and Best Practices
Moving from prototype to production requires more than just knowing the API. Here are patterns that keep scheduled jobs reliable at scale.
Dynamic Schedule Management at Runtime
Many applications let users configure schedules at runtime. The recommended pattern is to store scheduler metadata in your database and sync it to BullMQ on startup and changes:
async function syncSchedulesFromDB() {
const schedules = await db.query(
'SELECT * FROM schedules WHERE active = true'
);
for (const s of schedules) {
await queue.upsertJobScheduler(s.id, {
pattern: s.cron_expression,
tz: s.timezone,
}, {
name: s.job_name,
data: s.job_data,
});
}
}
// Call on app startup
await syncSchedulesFromDB();
// Call when a user creates/updates/deletes a schedule
// app.on('schedule-updated', async (schedule) => {
// await syncSchedulesFromDB();
// });
Because upsertJobScheduler is idempotent, it's safe to call repeatedly. Schedules that were removed from the database but still exist in Redis are left alone — consider adding a cleanup step that compares Redis state against your database.
Graceful Shutdown and Schedule Persistence
Schedules survive worker restarts because they're stored in Redis, not in-memory. When a worker shuts down:
- The worker finishes its current job (if
gracefulShutdownis configured). - The scheduled delayed job remains in Redis.
- When a worker reconnects, it picks up where things left off.
No special teardown is needed for scheduling itself — just close your workers cleanly.
Monitoring Scheduled Jobs
Use these practices to keep an eye on your schedules:
// Verify active schedules
const activeSchedulers = await queue.getJobSchedulers(0, 99, true);
console.log(`Active schedulers: ${activeSchedulers.length}`);
// Monitor delayed count for anomalies
const queueMetrics = await queue.getJobCounts('delayed');
// Alert if delayed count is zero for a known schedule
With Queue Hub, you can visually inspect:
- All active schedulers with their
repeatJobKeybadge - The schedule type (cron / interval / one-time delay)
- Delayed job counts in the queue overview dashboard
- A "Remove + Stop Schedule" action on scheduler-produced jobs
If a known schedule disappears from getJobSchedulers(), set up an alert. This can indicate accidental deletion, config drift, or a bug in your sync logic.
Common Mistakes
| Mistake | Consequence | Fix |
|---|---|---|
Forgetting connection in both Queue and Worker |
Scheduler mismatch → jobs not created | Share the same connection object |
Using every in seconds instead of milliseconds |
Jobs run 1000× too fast | Use milliseconds: every: 5000 = 5 seconds |
Not specifying tz for cron |
DST breaks, wrong hour | Always set tz for time-sensitive cron |
Putting repeat inside the third argument of upsertJobScheduler |
Scheduler ignores repeat config | Repeat config goes in the second argument |
| Removing/re-adding instead of upserting | Race condition with in-flight delayed job | Use upsertJobScheduler — it atomically replaces |
Observability with Queue Hub
Queue Hub is purpose-built for managing BullMQ and BeeQueue queues. For scheduling specifically, it provides:
- Add Job dialog with three schedule types: "One-time" (with delay input), "Cron pattern" (with 5-field cron editor), and "Interval" (repeat every N milliseconds).
- Job list view showing a
repeatJobKeybadge on scheduler-produced jobs. - "Remove + Stop Schedule" action that calls
removeRepeatableByKeyunder the hood. - Queue overview showing delayed job counts for health monitoring.
- Multi-backend support — the same scheduling features work for both BullMQ and BeeQueue queues.
Comparison: BullMQ vs. Alternatives for Scheduling
How does BullMQ stack up against other scheduling approaches in Node.js?
| Feature | BullMQ (Job Schedulers) | node-cron | Vercel Cron | setInterval |
|---|---|---|---|---|
| Persistence | ✅ Redis-backed | ❌ In-memory | ❌ Ephemeral | ❌ In-memory |
| Distributed | ✅ Multi-worker | ❌ Single process | ❌ Platform-bound | ❌ Single process |
| Retries/Backoff | ✅ Built-in | ❌ Manual wrap | ❌ None | ❌ Manual wrap |
| Dynamic (runtime) | ✅ upsertJobScheduler |
❌ Static | ❌ Static | ✅ But fragile |
| DST-aware | ✅ With tz |
❌ Manual | ✅ Platform | ❌ Manual |
| Monitoring | ✅ Queue Hub | ❌ None | ✅ Dashboard | ❌ None |
When to use what:
- BullMQ: Production queue systems with multi-service architectures, when you need observability, retries, and dynamic schedule management. Best choice when Redis is already part of your stack.
- node-cron / setInterval: Simple in-process schedules where Redis isn't available. Good for local development or single-process utilities.
- Vercel Cron: Serverless applications on Vercel where you need minimal setup and platform-managed execution. Limited to the cron patterns Vercel supports.
Conclusion
BullMQ's scheduling system is powerful, reliable, and production-tested. Here are the key takeaways:
- Three scheduling modes: Delayed (one-time), interval (every N milliseconds), and cron (pattern-based). Choose the right primitive for your use case.
- Use Job Schedulers for all new projects: The
upsertJobSchedulerAPI is idempotent, updatable, and supports job templates. It's the recommended approach since BullMQ v5.16.0. - No background daemon: Scheduling works via delayed-job chaining. No missed-job accumulation when workers are down.
- Always set timezone for cron: Without
tz, DST transitions will cause incorrect timing. IANA timezone names are your friend. - Monitor your schedules: Use
getJobSchedulers()to verify active schedules and a dashboard like Queue Hub for visual inspection.
Ready to put this into practice? Try adding a scheduled job with Queue Hub's visual scheduler — no code required to test your cron patterns. Once you've validated the timing, export the config directly to your application code. It's the fastest way to go from "I need a scheduled job" to "my job is running on schedule."
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.