Background work Advanced
Scheduled tasks
Run work on the clock, not a request: intervals, cron expressions, time zones, retries, overlapping runs and running on one instance with @zudojs/scheduler.
BY THE END OF THIS LESSON YOU CAN
- Define a job and schedule it to run once, repeatedly or on a cron expression
- Write and verify cron expressions, and run them in UTC to avoid daylight-saving gaps
- Retry a failing run, bound it with a timeout, and read its history from getExecutions
- Choose an overlap policy for a run that outlasts its own interval
- Make a duplicate run harmless when several instances share one schedule
Work that the clock starts
Everything the Task API has done so far started with a request: a user clicks, a job is queued. Some work has no user behind it. It starts because of the time:
- At 09:00 every weekday, email each user a digest of their open tasks.
- Every night, delete completed tasks older than 30 days.
- Every 5 minutes, refresh the cached statistics.
A scheduler runs a function at the times you describe. You could build a crude one with setTimeout, but you would soon need everything else: repeating on a calendar, stopping cleanly on shutdown, timeouts, retries, and not starting a second run while the first is still going. @zudojs/scheduler is that machinery.
A scheduler and a queue work together. The scheduler decides when. The queue from the background jobs lesson makes sure the work is done, with retries and dead letters. You will connect them at the end.
Install @zudojs/scheduler
npm install @zudojs/scheduler added 1 package, and audited 78 packages in 3s …
This package is not in the browser terminal, so every example on this page says Node.js only. Save each one in your task-api/src folder and run it with npx tsx src/<file>.ts. You will see the output shown under it.
Your first schedule
Three steps, always in this order: create a scheduler, define a job (give a function a name), then schedule that name. Nothing runs until you call start():
import { Scheduler } from "@zudojs/scheduler";
const scheduler = new Scheduler();
let runs = 0;
let done: () => void = () => {};
const threeRuns = new Promise<void>((resolve) => (done = resolve));
scheduler.define({
id: "refresh-stats",
name: "Refresh the cached statistics",
handler: (ctx) => {
runs += 1;
console.log(`run ${runs} of ${ctx.jobId}`);
if (runs === 3) done();
},
});
const handle = scheduler.every("50ms", "refresh-stats");
console.log("schedule is", handle.state);
scheduler.start();
await threeRuns;
await scheduler.stop();
console.log("stopped after", runs, "runs");
npx tsx first.tsschedule is active run 1 of refresh-stats run 2 of refresh-stats run 3 of refresh-stats stopped after 3 runs
- A job is a named function: an
id, anamefor people, and ahandler. Scheduling an id you never defined throws. - A schedule is a rule about when to run a job.
every("50ms", id)runs it every 50 milliseconds, starting 50 ms from now. In the Task API you would writeevery("5m", …). One job can have several schedules. everyreturns a handle, a remote control for that one schedule.stop()ends the timers. Call it when your app shuts down, from the same place that stops the queue workers.
Waiting for a promise that the third run resolves, instead of waiting a fixed time, keeps the example exact even on a slow computer.
Node.js exits when it has nothing left to wait for. A started scheduler counts as something to wait for, so a small process that only runs schedules keeps running until you call stop(). That is why the example must call stop() to finish. (If you ever want the old behaviour, where the scheduler alone does not keep Node.js running, pass keepAlive: false.)
Once, after, every
There are four ways to schedule a defined job:
| Call | Runs |
|---|---|
after("30s", id) | Once, 30 seconds from now |
at(new Date("2027-01-01T00:00:00Z"), id) | Once, at that instant |
every("5m", id) | Again and again, every 5 minutes |
cron("0 9 * * 1-5", id) | At calendar times: 09:00 on weekdays |
Durations are strings with a unit: ms, s, m, h, d and w, which you can combine. A bare number is rejected, because nobody can tell whether 5 means seconds or minutes:
import { InvalidDurationError, parseDuration } from "@zudojs/scheduler";
for (const text of ["250ms", "30s", "5m", "1h30m", "1d", "1w"]) {
console.log(text.padEnd(6), "=", parseDuration(text), "ms");
}
try {
parseDuration("5");
} catch (error) {
if (error instanceof InvalidDurationError) console.log("5 ->", error.name);
}
npx tsx durations.ts250ms = 250 ms 30s = 30000 ms 5m = 300000 ms 1h30m = 5400000 ms 1d = 86400000 ms 1w = 604800000 ms 5 -> InvalidDurationError
Each call can pass data, which the handler reads as ctx.data: scheduler.every("1h", "sync", { data: { source: "billing" } }).
Cron expressions
every("24h") runs 24 hours after the app started, which could be 15:37. For "09:00 every weekday" you need a calendar rule. A cron expression is the standard way to write one, from the Unix tool cron. It is five fields separated by spaces:
| Field | Values | Example |
|---|---|---|
| 1. minute | 0 to 59 | 0 = at minute 0 |
| 2. hour | 0 to 23 | 9 = 09:00 |
| 3. day of month | 1 to 31 | * = any day |
| 4. month | 1 to 12, or jan to dec | * = any month |
| 5. day of week | 0 to 6 (0 is Sunday), or sun to sat | 1-5 = Monday to Friday |
In each field, * means "any", 1-5 is a range, 1,15 is a list, and */15 means "every 15th": 0, 15, 30, 45. So 0 9 * * 1-5 reads "minute 0, hour 9, any day, any month, Monday to Friday".
Cron expressions are easy to get wrong, so check them. A CronTrigger computes the next run after any date you give it, without starting anything. Starting from a fixed date, Friday 2 October 2026 at 12:00 UTC, the answers are the same on every computer:
import { CronTrigger } from "@zudojs/scheduler";
const from = new Date("2026-10-02T12:00:00Z");
function nextRuns(expression: string, count: number): string {
const trigger = new CronTrigger(expression, "UTC");
const runs: string[] = [];
let after = from;
for (let i = 0; i < count; i++) {
const next = trigger.next(after);
if (!next) break;
runs.push(next.toISOString().slice(0, 16).replace("T", " "));
after = next;
}
return runs.join(" | ");
}
console.log("0 9 * * 1-5 ", nextRuns("0 9 * * 1-5", 3));
console.log("*/15 * * * * ", nextRuns("*/15 * * * *", 3));
console.log("30 2 * * * ", nextRuns("30 2 * * *", 3));
console.log("0 0 1 * * ", nextRuns("0 0 1 * *", 3));
console.log("@hourly ", nextRuns("@hourly", 2));
npx tsx cron.ts0 9 * * 1-5 2026-10-05 09:00 | 2026-10-06 09:00 | 2026-10-07 09:00 */15 * * * * 2026-10-02 12:15 | 2026-10-02 12:30 | 2026-10-02 12:45 30 2 * * * 2026-10-03 02:30 | 2026-10-04 02:30 | 2026-10-05 02:30 0 0 1 * * 2026-11-01 00:00 | 2026-12-01 00:00 | 2027-01-01 00:00 @hourly 2026-10-02 13:00 | 2026-10-02 14:00
- The weekday digest skips Saturday 3 and Sunday 4 October and first runs on Monday the 5th.
*/15starts at 12:15, not 12:00: the next run is always strictly after the date you give.@hourlyis a shortcut for0 * * * *.@daily,@weekly,@monthlyand@yearlyexist too.
THE TWO DAY FIELDS
When both day fields are set, cron runs when either matches, not both.0 0 13 * 5 does not mean "Friday the 13th": it runs on every 13th and on every Friday. Every cron tool behaves this way. Check an expression like this with CronTrigger before you trust it.A broken expression fails when you write it, not at 2 a.m.:
import { CronParseError, InvalidScheduleError, Scheduler } from "@zudojs/scheduler";
const scheduler = new Scheduler();
scheduler.define({ id: "digest", name: "Daily digest", handler: () => {} });
for (const expression of ["0 9 * *", "0 25 * * *", "0 0 30 2 *"]) {
try {
scheduler.cron(expression, "digest", { timezone: "UTC" });
} catch (error) {
if (error instanceof CronParseError || error instanceof InvalidScheduleError) {
console.log(expression.padEnd(11), "->", error.name);
console.log(" ", error.message);
}
}
}
npx tsx cron-errors.ts0 9 * * -> CronParseError
Invalid cron expression "0 9 * *": Cron expression must have 5 fields (minute hour day-of-month month day-of-week), got 4.
0 25 * * * -> CronParseError
Invalid cron expression "0 25 * * *": Cron field "hour" value 25 is outside 0-23.
0 0 30 2 * -> InvalidScheduleError
Recurring trigger has no future fire time ("0 0 30 2 *").Four fields instead of five, and hour 25, cannot be parsed, and the message names the expression and the field that is wrong. 0 0 30 2 * is valid cron, but 30 February never comes, so the schedule is refused.
Time zones
"09:00" is not a moment until you say where. 09:00 in Lagos and 09:00 in Tokyo are eight hours apart. By default, cron fields are read in the local time zone of the server, whatever the operating system is set to. Pass timezone: "UTC" to read them in UTC, the same everywhere, or pass any IANA zone name to read them as wall-clock time there:
import { Scheduler } from "@zudojs/scheduler";
import type { Clock } from "@zudojs/scheduler";
const now = new Date("2026-10-02T12:00:00Z");
const clock: Clock = { now: () => new Date(now), nowMs: () => now.getTime() };
const scheduler = new Scheduler({ clock });
scheduler.define({ id: "digest", name: "Weekday digest", handler: () => {} });
const digest = scheduler.cron("0 9 * * 1-5", "digest", { timezone: "UTC" });
console.log("next digest:", digest.nextRun()?.toISOString());
const lagos = scheduler.cron("0 9 * * 1-5", "digest", { timezone: "Africa/Lagos" });
console.log("Lagos next digest:", lagos.nextRun()?.toISOString());
npx tsx time-zones.tsnext digest: 2026-10-05T09:00:00.000Z Lagos next digest: 2026-10-05T08:00:00.000Z
Two things are new here:
- A clock is where the scheduler reads the current time: an object with
now()andnowMs(), described by theClocktype. This one always says 12:00 on 2 October, sonextRun()gives the same answer on every computer. That is how you test schedules without waiting for real time to pass. - Lagos is UTC+1 with no daylight saving, so 09:00 there is 08:00 UTC.
CronTriggerresolves any named zone through Node's ownIntltime-zone data (since@zudojs/scheduler1.3.0), so"Africa/Lagos","America/New_York"and"Asia/Kolkata"all work; only a nameIntldoes not recognise throws. ACronTrigger's own.timezoneproperty reports back the canonical zone name (undefinedfor local time), which is handy to log next to a schedule.
What makes a named zone worth using over a fixed UTC offset is daylight saving time. A schedule in America/New_York keeps firing at 09:00 wall-clock time on both sides of a clock change, because the package recomputes the UTC offset for each run instead of using one fixed number:
import { CronTrigger } from "@zudojs/scheduler";
const trigger = new CronTrigger("0 9 * * *", "America/New_York");
let after = new Date("2027-03-12T00:00:00Z"); // two days before the US spring-forward
for (let i = 0; i < 4; i++) {
const next = trigger.next(after);
if (!next) break;
console.log(next.toISOString());
after = next;
}
npx tsx dst.ts2027-03-12T14:00:00.000Z 2027-03-13T14:00:00.000Z 2027-03-14T13:00:00.000Z 2027-03-15T13:00:00.000Z
Before 14 March the offset is UTC-5 (EST), so 09:00 local is 14:00 UTC. The clocks in New York sprang forward that night, and from the 14th on the offset is UTC-4 (EDT): the same 09:00 wall-clock time is now 13:00 UTC. A schedule pinned to a fixed "UTC-5" offset would have fired an hour "late" by local time from that day on; a named zone does not drift.
Run schedules in UTC when the time itself has no local meaning (a nightly cleanup, an internal sync): it is one fewer thing to reason about, and servers move between regions and clouds. Use a named IANA zone when the time is about a place: "09:00 in Lagos" for a digest local users expect at breakfast, or "02:30 in America/New_York" for a maintenance window your ops team schedules by wall clock. Either way, a fixed-offset job (02:30 with no zone reasoning at all) can still be skipped or repeated across a clock change; a named zone is what keeps a local 09:00 meaning 09:00 all year. If users want their digest at 09:00 their time, store each user's IANA zone name and pass it straight to timezone, rather than computing the UTC time yourself.
Pausing, resuming, cancelling
The handle controls a live schedule. listSchedules() shows everything the scheduler knows:
import { Scheduler } from "@zudojs/scheduler";
const now = new Date("2026-10-02T12:00:00Z");
const scheduler = new Scheduler({
clock: { now: () => new Date(now), nowMs: () => now.getTime() },
});
scheduler.define({ id: "cleanup", name: "Delete old completed tasks", handler: () => {} });
scheduler.define({ id: "refresh-stats", name: "Refresh statistics", handler: () => {} });
const cleanup = scheduler.cron("30 2 * * *", "cleanup", { timezone: "UTC" });
const stats = scheduler.every("5m", "refresh-stats");
await stats.pause();
console.log("stats:", stats.state);
await stats.resume();
console.log("stats:", stats.state, stats.nextRun()?.toISOString());
await cleanup.cancel();
console.log("cleanup:", cleanup.state, cleanup.nextRun());
for (const schedule of scheduler.listSchedules()) {
console.log(schedule.jobId, schedule.type, schedule.state, schedule.nextRunAt.toISOString());
}
npx tsx handles.tsstats: paused stats: active 2026-10-02T12:05:00.000Z cleanup: cancelled undefined refresh-stats interval active 2026-10-02T12:05:00.000Z
A paused schedule does not fire. Resuming computes the next run from now: a schedule paused over three fire times does not fire three times at once when you resume it. A cancelled schedule is gone for good, and a run that was in progress is aborted through its signal.
Failed runs, retries and timeouts
A handler that throws does not crash the scheduler, and it is not silently forgotten. A job can have a retry policy. What still fails after the last retry goes to the scheduler's onError callback, and every run is kept in a short history:
import { Scheduler } from "@zudojs/scheduler";
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
const scheduler = new Scheduler({
onError: ({ jobId, error }) => {
if (error instanceof Error) console.log(`onError: ${jobId}: ${error.name}: ${error.message}`);
},
});
scheduler.define({
id: "sync-calendar",
name: "Sync the calendar",
options: { retry: { attempts: 3, strategy: "exponential", delay: 10 } },
handler: (ctx) => {
console.log(`sync-calendar attempt ${ctx.attempt}`);
if (ctx.attempt < 3) throw new Error("calendar API returned 503");
},
});
scheduler.define({
id: "build-report",
name: "Build the weekly report",
options: { timeout: 50 },
handler: async (ctx) => {
await wait(150);
console.log("build-report noticed the abort:", ctx.signal.aborted);
},
});
scheduler.after("10ms", "sync-calendar");
scheduler.after("100ms", "build-report");
scheduler.start();
await wait(500);
for (const run of scheduler.getExecutions()) {
console.log("history:", run.jobId, run.status, "after attempt", run.attempt);
}
await scheduler.stop();
npx tsx failures.tssync-calendar attempt 1 sync-calendar attempt 2 sync-calendar attempt 3 onError: build-report: SchedulerJobTimeoutError: Job timed out after 50ms. build-report noticed the abort: true history: sync-calendar completed after attempt 3 history: build-report timed_out after attempt 1
sync-calendarfailed twice and worked on the third attempt, soonErrornever heard about it.ctx.attemptstarts at 1.ctx.attemptNumberis the same number, under the name the queue uses.build-reportran past its 50 mstimeout. The scheduler abortedctx.signaland reported aSchedulerJobTimeoutError. As with every signal in this course, the handler has to look at it. Pass it on tofetchand database calls.getExecutions()keeps the last 100 runs with their status and the attempt they ended on:completed,failed,timed_outorcancelled. Other errors arrive inonErrorasSchedulerJobExecutionError, with the original error as itscause.
In the Task API, onError should log with the logger and raise an alert. A nightly job that fails every night for a month without anyone noticing is a classic production story.
Overlapping runs
You schedule a job every 10 minutes. One day the database is slow and a run takes 25 minutes. The next fire times arrive while the first run is still going. That is an overlap, and each schedule decides what to do with its overlap option:
import { Scheduler } from "@zudojs/scheduler";
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
async function tryPolicy(overlap: "allow" | "skip"): Promise<void> {
const scheduler = new Scheduler();
let running = 0;
let mostAtOnce = 0;
scheduler.define({
id: "slow-cleanup",
name: "A cleanup that is slower than its interval",
handler: async () => {
running += 1;
mostAtOnce = Math.max(mostAtOnce, running);
await wait(150);
running -= 1;
},
});
scheduler.every("40ms", "slow-cleanup", { overlap });
scheduler.start();
await wait(300);
await scheduler.stop();
console.log(`${overlap}: runs overlapped: ${mostAtOnce > 1}`);
}
await tryPolicy("allow");
await tryPolicy("skip");
npx tsx overlap.tsallow: runs overlapped: true skip: runs overlapped: false
"allow", the default, starts a new run next to the old one. Runs pile up, each one slowing the database further."skip"drops a fire time while a run is still going. For cleanups and syncs this is almost always what you want: the running one will do the work."queue"holds one fire time and runs it right after the current run ends."replace"aborts the running one and starts fresh, for when only the newest result matters.
new Scheduler({ maxConcurrency }) also caps how many runs of all jobs may be in progress at once. The default is 10.
Restarts and persistent schedules
The scheduler keeps everything in memory. Nothing is written to disk. When the process restarts, every schedule is gone. That sounds bad, but for most recurring work it is fine:
- Recurring schedules live in your code. The weekday digest and the nightly cleanup are defined when the app starts, every time it starts. The code is the source of truth, so there is nothing to lose.
- Schedules that users create must be stored. "Remind me on 12 October at 08:00" is data. Save it in the database, as in the database lesson, and when the app starts, read the rows and schedule them again.
When the app restores a reminder whose time already passed while it was down, the misfire option decides: "run-once" (the default) runs it once right away, and "skip" refuses it:
import { Scheduler } from "@zudojs/scheduler";
interface ReminderRow {
readonly id: number;
readonly taskId: number;
readonly remindAt: string;
}
const rows: ReminderRow[] = [
{ id: 1, taskId: 4, remindAt: "2026-10-02T08:00:00Z" },
{ id: 2, taskId: 7, remindAt: "2026-10-12T08:00:00Z" },
];
const now = new Date("2026-10-02T12:00:00Z");
const scheduler = new Scheduler({
clock: { now: () => new Date(now), nowMs: () => now.getTime() },
});
scheduler.define({ id: "task-reminder", name: "Send one task reminder", handler: () => {} });
for (const row of rows) {
const handle = scheduler.at(new Date(row.remindAt), "task-reminder", {
misfire: "run-once",
data: { reminderId: row.id, taskId: row.taskId },
});
console.log(`reminder ${row.id}: runs at ${handle.nextRun()?.toISOString()}`);
}
npx tsx restore.tsreminder 1: runs at 2026-10-02T12:00:00.000Z reminder 2: runs at 2026-10-12T08:00:00.000Z
Reminder 1 was due at 08:00, while the server was down. With run-once it runs at once, at 12:00. Reminder 2 is still in the future and keeps its time. The handler then marks the row as sent, so a later restart does not send it again.
TIP
For one-off work like a reminder, a delayed job on a stored queue (queue.add(name, data, { scheduledAt }) from the last lesson) is often simpler: the queue already stores it, retries it and survives restarts. Use the scheduler for recurring, clock-based work.Run it on one instance only
In production you usually run two or more copies of the Task API behind a load balancer, as you will in the deployment lesson. Each copy starts its own scheduler. At 09:00 each one sends the digest, and every user gets it twice, or ten times.
There are three common fixes:
- One scheduler process. Start the scheduler only where a setting says so, for example
SCHEDULER_ENABLED=trueon exactly one instance, or in a separate small "scheduler" service. Simple, but if that one instance is down, nothing runs. - A lock in a shared store. Every copy fires, but the job first takes a lock such as
digest.2026-10-05in Redis. Only the copy that gets it does the work. The lock from the caching lesson works this way when you give it a shared lock store. - Let the scheduler only enqueue. The scheduled handler does no work itself. It adds a job to a shared queue with a
deduplicationKeymade from the date. Duplicates are refused, and the real work gets the queue's retries and dead letters.
Here is the third pattern. Two schedulers stand for two servers, and they share one queue. Both fire, and both queue a job. The processor is idempotent, as in the last lesson: it records each day it has sent, so the second job does nothing:
import { createInMemoryQueue, createQueueName } from "@zudojs/queue";
import { Scheduler } from "@zudojs/scheduler";
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
const sentDays = new Set<string>();
const digests = createInMemoryQueue<{ day: string }>(createQueueName("digests"));
digests.process("send-digest", async (job) => {
if (sentDays.has(job.data.day)) {
console.log("worker: digest already sent today, skipping");
return;
}
sentDays.add(job.data.day);
console.log("worker: sending the digest");
});
function startServer(name: string): Scheduler {
const scheduler = new Scheduler();
scheduler.define({
id: "enqueue-digest",
name: "Enqueue the daily digest",
handler: async (ctx) => {
const day = ctx.scheduledAt.toISOString().slice(0, 10);
await digests.add("send-digest", { day });
console.log(`${name}: queued the digest`);
},
});
scheduler.after("20ms", "enqueue-digest");
scheduler.start();
return scheduler;
}
const a = startServer("server A");
const b = startServer("server B");
await wait(400);
await a.stop();
await b.stop();
await digests.close();
npx tsx one-instance.tsserver A: queued the digest server B: queued the digest worker: sending the digest worker: digest already sent today, skipping
Both "servers" fired and queued a job, but only one digest went out. The day comes from ctx.scheduledAt, the time the run was due, so tomorrow's run is a new day. In the real Task API, sentDays is a database table with a unique day column: the database then refuses the second insert even when two workers on two machines try at the same moment.
REASON IT OUT
Both servers already queue the digest job with the same deduplicationKey. Isn't that enough on its own, without an idempotent processor too?
Look again at how deduplicationKey works on the queue: it refuses a second job with the same key while a job with that key still exists in the queue. Server A's job runs and finishes in, say, 200 ms. Server B's own scheduler fires a little later — clocks and timers on two machines are never perfectly in step. Does the key still protect you at that point?
Show the reasoning
No: once server A's job has completed, it is no longer "in the queue" for deduplication purposes, so server B's job with the identical key is accepted as if it were the first one of the day, and the digest goes out twice. The key only protects against duplicates that arrive close together in time, while the original is still pending; it says nothing about a duplicate that arrives after the original already finished. The idempotent processor's check (sentDays.has(job.data.day), backed by a unique database column in the real app) protects you regardless of when the duplicate shows up: seconds later, or the next time the process restarts and re-reads a stored schedule. Use the deduplication key as a cheap first filter, but keep the idempotency check as the guarantee you actually rely on.
The production checklist for scheduled work:
- Run it on one instance, or make duplicates harmless with a lock or a deduplication key.
- Write cron expressions in UTC, and check them with
CronTrigger. - Choose
overlap: "skip"for anything that can run longer than its interval. - Give every job a
timeout, honourctx.signal, and send failures fromonErrorto your logs and alerts. - Keep handlers short. Heavy work goes on a queue.
- Call
stop()on shutdown. By default it aborts running jobs and waits for them to settle.stop({ drain: true })lets them finish instead. Callingstop()twice, or on a scheduler that never started, is safe.
Practice
TRY IT YOURSELF
Write the expressions
Write cron expressions for: (a) every day at 23:45; (b) every 10 minutes during office hours, 08:00 to 17:59, Monday to Friday; (c) at 06:00 on the first day of every quarter (January, April, July, October). Check each with CronTrigger from 2 October 2026, 12:00 UTC.
Write it in the editor, run it on your computer, then press Check and paste what it printed. Hints and the solution open up once you have checked your output.
HINT 1
(a) is minute 45, hour 23, every day. (b) combines a step (*/10) with a range for the hour (8-17) and a range for the weekday (1-5). (c) is minute 0, hour 6, day 1, and a list of four months.
HINT 2
"45 23 * * *", "*/10 8-17 * * 1-5", "0 6 1 1,4,7,10 *".
SOLUTION
import { CronTrigger } from "@zudojs/scheduler";
const from = new Date("2026-10-02T12:00:00Z");
const expressions = ["45 23 * * *", "*/10 8-17 * * 1-5", "0 6 1 1,4,7,10 *"];
for (const expression of expressions) {
const trigger = new CronTrigger(expression, "UTC");
const first = trigger.next(from);
const second = first ? trigger.next(first) : null;
console.log(expression.padEnd(18), first?.toISOString(), second?.toISOString());
}
npx tsx exercise-cron.ts45 23 * * * 2026-10-02T23:45:00.000Z 2026-10-03T23:45:00.000Z */10 8-17 * * 1-5 2026-10-02T12:10:00.000Z 2026-10-02T12:20:00.000Z 0 6 1 1,4,7,10 * 2027-01-01T06:00:00.000Z 2027-04-01T06:00:00.000Z
The quarterly job's next run is 1 January 2027: 1 October 2026 has just passed.
TRY IT YOURSELF
Skip the overlap
A "recalculate statistics" job runs every 30 seconds but sometimes takes 45 seconds. Which overlap policy should it use, and why? What would you change if it often took 2 minutes?
Work it out first, on paper or in your head. Then use the hints, and compare with the solution.
HINT 1
Re-read overlapping runs: which policy is right when a slightly-late run is harmless, because the currently running one will produce a fresh answer anyway?
HINT 2
"Sometimes 45 seconds" is an occasional overrun; "often 2 minutes" means the interval itself no longer fits the work. Those call for different fixes, not just a different overlap value.
SOLUTION
"skip": a run that is still going will produce fresh statistics anyway, and a second run in parallel only doubles the load. If it often takes 2 minutes, the interval is simply too short for the work. Make the interval longer, make the job faster (for example by only recalculating users whose tasks changed), or move the work to a queue.
Recap
- A scheduler runs work on the clock. Define a job, schedule it with
after,at,everyorcron, thenstart().stop()on shutdown. - A cron expression has five fields: minute, hour, day of month, month, day of week. Check expressions with
CronTriggerfrom a fixed date. - Use
timezone: "UTC". Local time has daylight-saving gaps and repeats. - Handles pause, resume and cancel schedules. A fixed
clockmakes schedules testable. - Retry policies, timeouts with
ctx.signal,onErrorandgetExecutions()keep failures visible. overlap: "skip"stops slow runs from piling up.- Nothing is stored: define recurring schedules in code, and store user-created ones in the database.
- Several instances each run the schedule. Run it on one, or make duplicates harmless with a lock or a queue deduplication key.
Next, Serialization covers what happens to a job's or a schedule's data once it has to cross a boundary as text: what plain JSON loses, and how to get it back.
Test yourself
Five questions, picked at random from this lesson's question bank. Some ask you to choose an answer, some to predict what code prints, and some to write code and run it in the terminal. Get 4 of 5 right to pass. If you don't, read the explanations and try again: you get 5 different questions.