Lesson 3 — Delayed & Scheduled Jobs

Goal: Run a job once, later (delayed) and run jobs on a recurring schedule (cron / fixed interval). This is the "when" axis of BullMQ — everything you need for reminder emails, nightly reports, and rate-cadenced batch work.

Tied to mission: a queue that only fires "as fast as possible" covers half your needs. The other half — "send the onboarding email 24h after signup", "reconcile invoices at 2am", "poll the API every 90s" — needs time. Builds on Lesson 1 and Lesson 2.

Two mental models — don't mix them up

NeedAPIMental model
Run this one job, laterqueue.add(..., { delay })A job sitting in a waiting room with a timer.
Run jobs on a schedulequeue.upsertJobScheduler(...)A factory that keeps emitting new jobs.

Delayed = one job, postponed. Scheduled = a Job Scheduler that produces many jobs over time. Everything below follows that split.

1. Delayed jobs — run once, later

A delayed job is dropped into a special delayed set instead of being grabbed by a worker immediately. Once the delay elapses it is promoted to waiting and processed like any other job.

import { Queue } from 'bullmq';
const paint = new Queue('Paint', { connection });

// Run ~5 seconds from now
await paint.add('house', { color: 'white' }, { delay: 5000 });
"At least" the delay, not "exactly". If all workers are busy when the timer fires, the job waits in line. In practice it's accurate; under heavy load it can lag. Never build logic that assumes millisecond-exact timing.

Aim at a specific clock time

delay is always a duration from now. To hit a wall-clock moment, compute the remaining duration:

const target = new Date('2035-07-03T10:30:00');
const delay = Number(target) - Number(new Date());
await paint.add('house', { color: 'white' }, { delay });

Reschedule a delayed job after it's queued

const job = await paint.add('house', { color: 'blue' }, { delay: 2000 });
// push it out to 4s from now
await job.changeDelay(4000);
changeDelay(ms) works only while the job is still in the delayed state. Once it's waiting/active, it's too late.

2. Scheduled jobs — the Job Scheduler (factory)

A Job Scheduler is a factory that emits jobs on a repeat setting. For historical reasons its output is often still called "repeatable jobs", but the object you manage is the scheduler, not the jobs. You create one with upsertJobScheduler:

const report = new Queue('report', { connection });

// Emit a job every 10 seconds. Returns the FIRST job (in 'delayed' state).
await report.upsertJobScheduler(
  'nightly-recon',        // scheduler id — stable, your key for update/remove
  { every: 10_000 },       // repeat setting
  {
    name: 'recon',
    data: { tenant: 'acme' },
    opts: { attempts: 5, removeOnComplete: 100 },
  },
);

Why "upsert" and not "add"

Calling upsertJobScheduler with an id that already exists updates it instead of creating a duplicate. This is idempotent and exactly what you want in prod: your app can call it on every boot without spawning extra schedules. add would pile up duplicates.

The two built-in repeat strategies

StrategyOptionWhen to use
every{ every: ms }Fixed interval: "every 90 seconds". Aligns to the clock, not to when you added it.
cron (pattern){ pattern: '…' }Calendar rules: "9am on weekdays", "last day of the month". Uses cron-parser.
Pick one. You cannot combine every and pattern on the same scheduler — set exactly one.

Cron anatomy

field:   (s)   m     h    DOM   M    DOW
         '0 0 9 * * 1-5'  →  9:00:00, Monday–Friday
          │ │ │ │ │ │
          │ │ │ │ │ └── day of week (0-7, 0 or 7 = Sunday; 1L-7L = last weekday)
          │ │ │ │ └─── month (1-12)
          │ │ │ └──── day of month (1-31, L = last day)
          │ │ └────── hour (0-23)
          │ └──────── minute (0-59)
          └────────── second (0-59, optional — 6-field form)
await report.upsertJobScheduler(
  'weekday-standup',
  { pattern: '0 0 9 * * 1-5' },   // 9:00 Mon–Fri
  { name: 'standup-report', data: {} },
);

Common recipes: '* * * * *' every minute · '0 * * * *' top of each hour · '0 0 * * 0' every Sunday midnight · '0 0 0 L * *' last day of month at midnight.

Repeat options (work on every strategy)

OptionEffect
startDateDon't produce anything before this date.
endDateExpiry — after this, no more jobs are produced.
limitMax number of repetitions; stops the scheduler after that many.
immediatelyForce the first job to run now, instead of waiting for the interval's next clock tick. (v5.19+)
The every gotcha. With every: 2000, jobs land on clock-aligned ticks (0, 2, 4, 6s…), not "2s after I called upsert". If you need it to start processing right away, add immediately: true.

Production gotchas you will hit

GotchaWhat happens
No custom job idScheduler-produced jobs get a special id to guarantee they're never created more often than the repeat setting. You cannot set your own. Use the job name to tell them apart.
Cadence drifts under loadThe scheduler only emits the next job after the previous one has started processing. A busy queue or too few workers → jobs arrive less often than the interval. Schedulers are not real-time.
Legacy API still in old codeYou'll see queue.add(name, data, { repeat }) + removeRepeatableByKey. That's the deprecated form. Use upsertJobScheduler for new code; the two coexist but don't manage each other.

3. Managing schedulers

// Remove by id → returns true if it existed, false otherwise
await report.removeJobScheduler('nightly-recon');

// Update: just upsert again with the same id and new settings
await report.upsertJobScheduler('nightly-recon', { every: 30_000 }, { name: 'recon' });

// List them all (paginated, start/end offsets)
const schedulers = await report.getJobSchedulers(0, 100);

Because removal is by the stable id, store that id somewhere (config, DB). Lose the id and you'll have to getJobSchedulers and hunt for it.

4. The full picture (TypeScript, typed job)

import { Queue, Worker } from 'bullmq';
import IORedis from 'ioredis';

const connection = new IORedis({ maxRetriesPerRequest: null });

type ReportData = { tenant: string };

const report = new Queue<ReportData>('report', { connection });

// Delayed: one report 1 hour from now
await report.add('ad-hoc', { tenant: 'acme' }, { delay: 3_600_000 });

// Scheduled: every weekday at 9am, capped at 250 runs, starts immediately
await report.upsertJobScheduler(
  'weekday-9am',
  { pattern: '0 0 9 * * 1-5', limit: 250, immediately: true },
  { name: 'daily-summary', data: { tenant: 'acme' }, opts: { attempts: 5 } },
);

new Worker<ReportData>('report', async (job) => {
  console.log(`${job.name} for ${job.data.tenant}`);
}, { connection });
Tangible win. You can now express time: "later" (delay) and "on a schedule" (upsertJobScheduler with every or cron pattern). Combined with retries from Lesson 2, you have a self-healing, time-aware worker — which is most of what production needs.

What you just learned


🧠 Quiz — 10 questions

1. Which option postpones a single job?

2. In what unit is delay measured?

3. How do you reschedule a job already delayed?

4. A scheduler's first returned job is in which state?

5. Which method creates a recurring scheduler?

6. Why "upsert" rather than "add"?

7. Cron 0 0 9 * * 1-5 runs when?

8. Which option caps total repetitions?

9. Under heavy load, scheduler cadence does what?

10. How do you delete a scheduler?


📚 Primary source

Delayed · Job Schedulers · Repeat Strategies · Repeat Options · Manage Job Schedulers (official). ~20 min total.

📎 Reference

Scheduling Cheatsheet — one-screen lookup for delay, upsertJobScheduler, cron, and the repeat options.

💬 Ask me next

Or say "next" for Lesson 4: priority queues & rate limiting.