A Fastify plugin that schedules jobs with
node-cron. It is a drop-in-friendly
alternative to @fastify/schedule:
same registration model and lifecycle, same decorator name, but backed by
node-cron, so you get cron expressions, distributed-ready scheduling, background
tasks in a forked process, timezones, per-execution events and more.
- Familiar. Register the plugin, get
fastify.scheduler. Jobs start onready, stop onclose. Just like@fastify/schedule. - More powerful. Real cron expressions (with seconds), distributed-ready scheduling, background tasks, timezones, overlap control, max executions.
- Both module systems. Ships ESM and CommonJS builds plus type definitions.
On "distributed". This plugin makes a task coordination-ready (
distributed: true) and lets you plug in a coordinator. It does not itself perform fleet-wide, per-fire election. That is the job of a pluggableRunCoordinator; for highly available, once-per-fire coordination across a fleet, use@node-cron/redis-coordinator.
npm install @node-cron/fastify node-cron fastifyfastify (v5) and node-cron (>= 4.4.1) are peer dependencies, so the plugin
shares the single copy your app already has. The only runtime dependency the
plugin adds is fastify-plugin.
importFastifyfrom"fastify";import{fastifyNodeCron}from"@node-cron/fastify";constapp=Fastify();awaitapp.register(fastifyNodeCron,{tasks: [{cron: "0 3 * * *",// every day at 03:00name: "nightly-backup",run: async()=>{awaitrunBackup();},},],});awaitapp.listen({port: 3000});Tasks declared in options.tasks are created immediately and start when
Fastify emits onReady, then are destroyed on onClose.
You can also schedule imperatively from a route or another plugin, through the decorator:
app.get("/enable-report",async()=>{app.scheduler.schedule("*/5 * * * *",()=>sendReport(),{name: "report"});return{scheduled: true};});The decorator is available as both fastify.scheduler and fastify.cron
(the same object), so fastify.scheduler === fastify.cron.
The registration, the decorator name and the lifecycle are the same. What
changes is how you describe a job: a cron expression instead of a
toad-scheduler Job object.
Before:
import{fastifySchedule}from"@fastify/schedule";import{AsyncTask,SimpleIntervalJob}from"toad-scheduler";awaitapp.register(fastifySchedule);consttask=newAsyncTask("poll",()=>pollForData());constjob=newSimpleIntervalJob({minutes: 5},task);app.scheduler.addSimpleIntervalJob(job);After:
import{fastifyNodeCron}from"@node-cron/fastify";awaitapp.register(fastifyNodeCron);app.scheduler.schedule("*/5 * * * *",()=>pollForData(),{name: "poll"});Or declare it up front in the plugin options:
awaitapp.register(fastifyNodeCron,{tasks: [{cron: "*/5 * * * *",name: "poll",run: ()=>pollForData()}],});Note that toad-scheduler's interval jobs ({ seconds }, { minutes }) map to
cron expressions. For sub-minute work, node-cron supports a 6-field expression
with seconds, e.g. "*/20 * * * * *" for "every 20 seconds".
awaitapp.register(fastifyNodeCron,{tasks: [/* FastifyNodeCronTaskDefinition[] */],
runCoordinator,// RunCoordinator applied process-wide (distributed tasks)
logger,// custom logger applied process-wideautoStart: true,// start tasks on `onReady` (default true)cron: nodeCron,// inject a specific node-cron instance (advanced/tests)});| Option | Type | Description |
|---|---|---|
tasks | FastifyNodeCronTaskDefinition[] | Tasks to register at startup. Each is { cron, run, ...nodeCronTaskOptions }. |
runCoordinator | RunCoordinator | Coordinator for distributed: true tasks, set via cron.setRunCoordinator. See Distributed. |
logger | Logger | Custom logger, set via cron.setLogger. Defaults to the Fastify logger for task start/teardown failures. |
autoStart | boolean | When false, nothing starts automatically. Manage it via app.scheduler.start() / stop(). |
cron | NodeCron | A node-cron instance to use instead of the bundled one. |
A task definition is the node-cron task options plus cron and run:
{cron: "0 3 * * *",// required: the cron expressionrun: ()=>doWork(),// required: inline function OR a file pathname: "nightly-backup",// node-cron options belowtimezone: "America/Sao_Paulo",distributed: true,noOverlap: true,maxExecutions: 10,// ...any other node-cron TaskOptions}fastify.scheduler (alias fastify.cron) exposes a small node-cron-native API:
app.scheduler.schedule(expression,run,options?)// -> ScheduledTaskapp.scheduler.getTask(id)// -> ScheduledTask | undefined (by node-cron id)app.scheduler.getTaskByName(name)// -> ScheduledTask | undefined (by your name)app.scheduler.getTasks()// -> Map<string, ScheduledTask> (keyed by id)app.scheduler.start()// start owned tasksapp.scheduler.stop()// stop owned tasks (keep them)app.scheduler.close()// destroy owned tasksapp.scheduler.cron// the underlying node-cron instanceschedule returns the node-cron ScheduledTask,
so you can subscribe to its lifecycle events:
consttask=app.scheduler.schedule("* * * * *",()=>work(),{name: "work"});task.on("execution:started",()=>app.log.info("work started"));task.on("execution:failed",(ctx)=>app.log.error(ctx.execution?.error));Run N replicas with the same cron and the job fires N times. Mark a task
distributed and give it a name, and node-cron coordinates so it runs once
per fire. Provide a runCoordinator to decide who runs.
For high availability across a fleet, use
@node-cron/redis-coordinator:
every instance runs the same schedule and exactly one wins each fire via a Redis
lock, surviving the loss of any node.
importFastifyfrom"fastify";import{createClient}from"redis";import{fastifyNodeCron}from"@node-cron/fastify";import{RedisLockCoordinator}from"@node-cron/redis-coordinator";constredis=createClient();awaitredis.connect();constapp=Fastify();awaitapp.register(fastifyNodeCron,{// applied process-wide; used by every `distributed: true` taskrunCoordinator: newRedisLockCoordinator(redis),tasks: [{cron: "0 3 * * *",name: "nightly-backup",run: ()=>runBackup(),distributed: true,distributedLease: 5*60_000,// longer than the task's worst-case runtime},],});You bring the Redis client; the coordinator just uses it (it works with both
redis and ioredis). See the
redis-coordinator docs for the
guarantees and distributedLease guidance. You can also set a coordinator per
task via the task's own runCoordinator option, which overrides the plugin-wide
one.
Process-wide state. The plugin's
runCoordinatorandloggeroptions are applied to node-cron globally (cron.setRunCoordinator/cron.setLogger), not per Fastify instance. node-cron keeps these as module-level singletons, so if you register the plugin on more than one Fastify instance in the same process (e.g. in tests, or a multi-tenant setup), the last registration wins. To keep instances isolated, prefer the per-taskrunCoordinator(andlogger) options, which scope to a single task and override the global one. Most apps run a single instance per process and never hit this.
node-cron can run a task in a forked process by pointing run at a module
file. Because the plugin sits between your app and node-cron, pass an
absolute path or a file:// URL (relative paths would resolve against this
package, not your app):
importpathfrom"node:path";awaitapp.register(fastifyNodeCron,{tasks: [{cron: "0 * * * *",name: "heavy-report",run: path.join(import.meta.dirname,"tasks","heavy-report.js"),},],});- Tasks created from
options.tasksare created stopped and start ononReady(unlessautoStart: false). - Tasks scheduled at runtime after
onReadystart immediately. - All tasks owned by the scheduler are destroyed on
onClose. - A failed start (e.g. a background task file that fails to load) is logged, not thrown, so it never becomes an unhandled rejection.
The package is written in TypeScript and ships type definitions, including the
Fastify module augmentation, so fastify.scheduler and fastify.cron are typed
once you import the plugin. It re-exports the node-cron types you commonly need
(TaskFn, TaskOptions, TaskContext, ScheduledTask, RunCoordinator,
Logger, ...).
ISC