·QueueHub Team·16 min read

BullMQ Job Scheduling: Complete Guide to Repeatable Jobs, Delays, and Cron Patterns

BullMQjob schedulingcronrepeatable jobsdelayed jobsRedis queuesbackground jobs

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:

  1. Delayed jobs — one-time, execute after N milliseconds
  2. Repeatable jobs (legacy) — recurring via queue.add() with repeat option
  3. 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:

  • QueueScheduler class: 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
  • repeat option on queue.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
  • 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:

  1. schedulerId — unique identifier for the scheduler
  2. repeatOptsevery, pattern, startDate, endDate, limit, immediately
  3. template (optional) — name, data, opts for 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: 2000 fires 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 Articles