NestJS scheduling, upgraded: a drop-in @nestjs/schedule replacement
@nestjs/schedule is what most NestJS apps reach for, and for a single instance running a few jobs it is fine. It gives you @Cron, @Interval and @Timeout, wired into DI, and that is the whole story. The ceiling shows up the moment your app grows:
- You scale to more than one replica, and every job fires on every instance.
- You have a heavy job that blocks the event loop, and there is no way to isolate it.
- You want to observe runs (succeeded? failed? how long?), and there are no events.
- You need per-task timezones that survive DST, jitter, or an execution cap, and they are not there.
@node-cron/nestjs is a drop-in replacement backed by node-cron. Same decorators, same ScheduleModule, same SchedulerRegistry. You migrate by swapping one import:
- import { ScheduleModule, Cron, Interval, Timeout, CronExpression, SchedulerRegistry } from '@nestjs/schedule';
+ import { ScheduleModule, Cron, Interval, Timeout, CronExpression, SchedulerRegistry } from '@node-cron/nestjs';Your decorated methods, the CronExpression values, and ScheduleModule.forRoot() stay exactly as they are. Here is what that swap buys you.
Run once across a fleet
This is the big one. Three replicas behind a load balancer all run the same @Cron, so the nightly job runs three times. The usual reaction is "node-cron does not scale, reach for a queue." Neither is right: it is a coordination problem, and @node-cron/nestjs solves it with one option.
@Cron('0 3 * * *', { name: 'nightly-backup', distributed: true })
handleBackup() {
// runs on exactly one instance per fire
}Out of the box, a NODE_CRON_RUN env var designates one runner (zero dependencies). For real high availability, where any instance can win each fire and it survives a node going down, pass a Redis coordinator to the module:
ScheduleModule.forRoot({ coordinator: new RedisLockCoordinator(redis) })@nestjs/schedule has no equivalent. (The full story is in Distributed Coordination.)
Heavy jobs in their own process
Decorate a property with @BackgroundCron and the job runs in a forked child process, so a CPU-heavy report never stalls your API:
@Injectable()
export class ReportTask {
@BackgroundCron('0 * * * *', { name: 'report' })
taskFile = __filename; // the task lives in `export const task` in this file
}It works with distributed: true too: heavy and once-per-fire across the fleet.
Observability for free
Because SchedulerRegistry.getCronJob(name) returns a node-cron ScheduledTask (not the cron package's CronJob), you can subscribe to lifecycle events and drive the job:
const task = this.registry.getCronJob('nightly-backup');
task.on('execution:failed', (ctx) => alert(ctx.execution?.error));
task.getNextRun(); // Date | null
task.execute(); // run it now, off-scheduleMigrating: the two things that differ
The decorators are identical, so most apps need no code changes. Two intentional differences are worth a grep:
getCronJob(name)returns aScheduledTask. Update calls to the oldCronJobAPI:nextDate()becomesgetNextRun(),runningbecomesgetStatus()/isBusy(),fireOnTick()becomesexecute().utcOffsetis ignored (with a warning). node-cron schedules by IANA timezone, which is more robust across DST. UsetimeZone: 'America/Sao_Paulo'instead of a raw offset.
That is the whole migration. Full details, options, and the SchedulerRegistry equivalence table are in the NestJS guide.
When @nestjs/schedule is enough
If you run a single instance, never block the loop, and do not care about events, the built-in is fine, no need to switch. The case for @node-cron/nestjs is everything past that: fleets, heavy jobs, observability. And since it is a one-line swap, you do not have to decide up front; adopt it when you hit the wall.
- Guide:
@node-cron/nestjs - Running across replicas? Distributed Coordination