Task Scheduling
@rudderjs/schedule is the framework's cron-based task scheduler. You define recurring jobs in routes/console.ts with a fluent frequency API, then run a single supervisor command (pnpm rudder schedule:work) that fires the right tasks at the right times. No external cron entries needed.
Setup
pnpm add @rudderjs/scheduleThe provider is auto-discovered. Schedule definitions live in routes/console.ts:
// routes/console.ts
import { schedule } from '@rudderjs/schedule'
schedule.call(async () => {
await syncExternalData()
}).everyFiveMinutes().description('Sync external data')
schedule.call(async () => {
await generateDailyReport()
}).daily().description('Generate daily report')
schedule.call(async () => {
await sendMorningDigest()
}).cron('0 9 * * 1-5').timezone('America/New_York').description('Morning digest')schedule.call(fn) returns a ScheduledTask with a fluent API. Chain frequency, time, timezone, and constraints; finish with .description(...) for the schedule listing.
Running the scheduler
In production, run a supervisor process that ticks once per second:
pnpm rudder schedule:workThis is the only command that should run as a long-lived process. Run it under systemd, pm2, Docker restart: always, or your platform's equivalent. For serverless deployments, configure the platform's cron to invoke pnpm rudder schedule:run once per minute — the runner picks the tasks due in that minute and exits.
To list everything scheduled:
pnpm rudder schedule:list
# */5 * * * * Sync external data
# 0 0 * * * Generate daily report
# 0 9 * * 1-5 Morning digest (America/New_York)Frequency helpers
The full set of fluent helpers, most-used first:
| Method | Cron | Description |
|---|---|---|
.everyMinute() |
* * * * * |
Every minute |
.everyFiveMinutes() |
*/5 * * * * |
Every 5 minutes |
.everyFifteenMinutes() |
*/15 * * * * |
Every 15 minutes |
.hourly() |
0 * * * * |
Top of every hour |
.hourlyAt(min) |
M * * * * |
Specific minute each hour |
.daily() |
0 0 * * * |
Midnight daily |
.dailyAt('H:M') |
M H * * * |
Specific time daily |
.twiceDaily(h1, h2) |
0 H1,H2 * * * |
Twice a day |
.weekly() |
0 0 * * 0 |
Sunday midnight |
.weeklyOn(day, 'H:M') |
custom | Specific weekday + time |
.monthly() |
0 0 1 * * |
1st of each month |
.yearly() |
0 0 1 1 * |
Jan 1st |
.cron(expr) |
custom | Raw cron expression |
For weekday filtering: .weekdays(), .weekends(), .mondays() … .sundays(). Each of these sets a complete cron expression — they do not compose with a time helper. Chaining .weekdays().dailyAt('9:00') overwrites the day filter and yields 0 9 * * * (every day). For "9am Monday through Friday" use a raw expression: .cron('0 9 * * 1-5').
Timezones
schedule.call(syncData)
.hourly()
.timezone('America/New_York') // run at NY time, not UTC
.description('Sync (NY hours)')By default, all times are UTC. Setting .timezone('America/New_York') shifts the cron evaluation to that zone.
There is no built-in .between(...) window or .when(...) / .skip(...) predicate today — branch inside the callback to skip a run, or use .evenInMaintenanceMode() to opt a task into running when the app is otherwise paused:
schedule.call(async () => {
if (isHoliday()) return
if (new Date().getHours() < 9 ||
new Date().getHours() >= 17) return
await syncData()
}).hourly().timezone('America/New_York')Lifecycle hooks
schedule.call(syncOrders)
.hourly()
.before(() => log.info('starting'))
.after(() => log.info('done'))
.onSuccess(() => metrics.increment('sync.success'))
.onFailure((err) => report(err))Preventing overlap
For tasks that may run longer than their interval, prevent overlap:
schedule.call(syncData)
.everyMinute()
.withoutOverlapping()
.description('Sync (no overlap)')withoutOverlapping() uses a cache lock keyed by the task, so it requires @rudderjs/cache to be registered. Without a cache there is no lock at all — the overlap guard is skipped entirely, even within a single process. Register a cache for it to take effect.
Single-server execution
For multi-server deployments where the same schedule runs on every host, mark a task to fire only once across the cluster:
schedule.call(generateMonthlyReport)
.monthly()
.onOneServer()This also uses cache locks — Redis-backed cache is required.
Running rudder commands on a schedule
schedule.call(callback) is the only entry point — there is no schedule.command() shortcut. Either extract the work into a plain function that both your rudder command and the schedule call into, or shell out via @rudderjs/process:
// Recommended — share the work as a function
import { cleanupDatabase } from '../app/Services/cleanup.js'
schedule.call(() => cleanupDatabase()).daily()
// Or shell out (prints the command's logs as well)
import { Process } from '@rudderjs/process'
schedule.call(async () => {
await Process.run('pnpm rudder cache:clear --store=redis')
}).hourly()Testing
Schedule tasks are plain functions wrapped by the fluent API. To test the work itself, extract the function and unit-test it directly. To test that a task fires on the right schedule, inspect pnpm rudder schedule:list.
Pitfalls
schedule:worknot running. Tasks are inert until the supervisor fires them. Confirm the process is alive in production.- Timezone surprises. Default is UTC. A
.daily()without.timezone(...)runs at UTC midnight, not local midnight. - Cron-based deploys clearing schedule lock. If you use
--forcedeploys that wipe the cache, in-flightwithoutOverlapping()locks evaporate. Use@rudderjs/cacheRedis driver and don't flush it on deploy. - Two schedulers running. Don't run
schedule:workon every server in a multi-instance deploy. Either run it on one designated host, or use.onOneServer()per task plus shared cache.