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.
| Need | API | Mental model |
|---|---|---|
| Run this one job, later | queue.add(..., { delay }) | A job sitting in a waiting room with a timer. |
| Run jobs on a schedule | queue.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.
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 });
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 });
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.
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 },
},
);
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.
| Strategy | Option | When 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. |
every and pattern on the same scheduler — set exactly one.
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.
| Option | Effect |
|---|---|
startDate | Don't produce anything before this date. |
endDate | Expiry — after this, no more jobs are produced. |
limit | Max number of repetitions; stops the scheduler after that many. |
immediately | Force the first job to run now, instead of waiting for the interval's next clock tick. (v5.19+) |
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.
| Gotcha | What happens |
|---|---|
| No custom job id | Scheduler-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 load | The 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 code | You'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. |
// 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.
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 });
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.
delay (ms) postpones one job; aim at a clock time by computing the duration.job.changeDelay(ms) reschedules, but only while the job is still delayed.upsertJobScheduler(id, repeat, template).every (clock-aligned) or pattern (cron) — never both.startDate, endDate, limit, immediately.removeJobScheduler(id) and getJobSchedulers(); re-upsert to update.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?
delay, upsertJobScheduler, cron, and the repeat options.
Or say "next" for Lesson 4: priority queues & rate limiting.