Skip to content
Warlock.js v4

Configuration

The Scheduler class is configurable through a set of chainable methods. All configuration must happen before calling .start().

The scheduler checks for due jobs on a recurring tick. The default tick interval is 1000 ms (1 second). Increase it to reduce CPU overhead when your shortest job interval is longer than a second:

The tick interval is drift-compensated — if a tick’s work takes 200 ms, the next tick fires 800 ms later (not 1000 ms), so the cadence between tick starts averages the configured interval rather than interval + work-time. Long-running jobs still block the next tick (the scheduler is sequential around its loop), but short ticks don’t slowly drift behind the wall clock.

import { Scheduler, job } from "@warlock.js/scheduler";
const myScheduler = new Scheduler();
myScheduler
.runEvery(60_000) // check for due jobs once per minute
.addJob(job("report", generateReport).daily().at("09:00"))
.addJob(job("cleanup", cleanupFn).weekly().on("sunday").at("02:00"))
.start();

The minimum allowed interval is 100 ms. Smaller values throw an error:

myScheduler.runEvery(50); // throws: "Tick interval must be at least 100ms"

By default, jobs that are due at the same tick run sequentially (one after the other). Enable parallel mode to run them concurrently:

import { Scheduler, job } from "@warlock.js/scheduler";
const myScheduler = new Scheduler();
myScheduler
.runInParallel(true) // enable parallel execution
.addJob(job("job-a", taskA).everyHour())
.addJob(job("job-b", taskB).everyHour())
.addJob(job("job-c", taskC).everyHour())
.start();

All three jobs will start simultaneously on each hourly tick.

Pass a second argument to cap how many jobs run at once:

myScheduler.runInParallel(true, 5); // at most 5 concurrent jobs per tick

When more jobs are due than the concurrency limit allows, they are batched: the first 5 start together, the next batch starts after the first finishes, and so on.

The default max concurrency is 10.

import { scheduler, job } from "@warlock.js/scheduler";
// Add a single job
scheduler.addJob(job("cleanup", cleanupFn).daily().at("03:00"));
// Add multiple jobs at once
scheduler.addJobs([
job("job-a", taskA).everyHour(),
job("job-b", taskB).daily(),
job("job-c", taskC).weekly(),
]);
// Fluent chaining
scheduler
.addJob(job("a", taskA).everyMinutes(5))
.addJob(job("b", taskB).everyMinutes(10));

newJob() creates, registers, and returns a job in one call:

scheduler
.newJob("cleanup", cleanupFn)
.daily()
.at("03:00");
const removed = scheduler.removeJob("cleanup"); // true if found and removed
const j = scheduler.getJob("cleanup");
if (j) {
console.log("Next run:", j.nextRun?.toISOString());
console.log("Is running:", j.isRunning);
}
const all = scheduler.list(); // readonly Job[]
console.log(`${scheduler.jobCount} jobs registered`);

Prepares all registered jobs and begins the tick loop. Throws if the scheduler is already running or has no jobs:

scheduler.start();
// throws "Cannot start scheduler with no jobs." if addJob() was never called
// throws "Scheduler is already running." if called twice

Immediately halts the tick loop. Any jobs currently executing are not waited on:

scheduler.stop();
// scheduler.isRunning === false

Calling stop() on a scheduler that was never started (or was already stopped) is a safe no-op — no scheduler:stopped event is emitted in that case.

Gracefully stops the scheduler. It:

  1. Stops scheduling new ticks
  2. Waits for all currently-running jobs to finish
  3. Times out after the specified duration (default 30 000 ms)
// Default 30-second timeout
await scheduler.shutdown();
// Custom timeout
await scheduler.shutdown(60_000); // wait up to 60 s

Wire this to your process signal handlers:

import { scheduler } from "@warlock.js/scheduler";
scheduler.start();
process.on("SIGTERM", async () => {
await scheduler.shutdown(30_000);
process.exit(0);
});
process.on("SIGINT", async () => {
await scheduler.shutdown(5_000);
process.exit(0);
});
console.log(scheduler.isRunning); // boolean
console.log(scheduler.jobCount); // number of registered jobs

The CronParser class and parseCron() helper are exported for use outside of scheduling contexts — for example, to preview the next run time of a cron expression:

import { parseCron, CronParser } from "@warlock.js/scheduler";
import type { CronFields } from "@warlock.js/scheduler";
// Quick utility
const parser = parseCron("0 9 * * 1-5");
const nextRun = parser.nextRun();
console.log("Next:", nextRun.toISOString());
// Check if right now matches
console.log("Matches now:", parser.matches(dayjs()));
// Inspect the parsed fields
const fields: CronFields = parser.fields;
// { minutes: [0], hours: [9], daysOfMonth: [1..31], months: [1..12], daysOfWeek: [1,2,3,4,5] }
// Access the original expression
console.log(parser.expression); // "0 9 * * 1-5"
import { Scheduler, job } from "@warlock.js/scheduler";
import { logger } from "./logger";
const appScheduler = new Scheduler();
// Observability
appScheduler.on("job:start", (name) => logger.debug(`Job started: ${name}`));
appScheduler.on("job:complete", (name, result) => logger.info({ name, duration: result.duration }, "Job complete"));
appScheduler.on("job:error", (name, error) => logger.error({ name, error }, "Job failed"));
appScheduler.on("job:skip", (name, reason) => logger.warn({ name, reason }, "Job skipped"));
// Configuration
appScheduler
.runEvery(1_000) // check every second
.runInParallel(true, 5) // up to 5 concurrent jobs
// Jobs
.addJob(
job("token-cleanup", deleteExpiredTokens)
.daily()
.at("03:00")
.preventOverlap()
.retry(3, 2000)
)
.addJob(
job("weekly-report", generateWeeklyReport)
.weekly()
.on("monday")
.at("08:00")
.inTimezone("America/New_York")
.preventOverlap()
)
.addJob(
job("heartbeat", pingExternalService)
.everyMinutes(5)
.retry(2, 1000, 2)
);
appScheduler.start();
logger.info("Scheduler started with", appScheduler.jobCount, "jobs");
// Graceful shutdown
process.on("SIGTERM", async () => {
logger.info("Shutting down scheduler…");
await appScheduler.shutdown(30_000);
logger.info("Scheduler shut down cleanly");
process.exit(0);
});