Task Scheduling with Cron
Most real-world applications need to do work on a schedule: clearing expired sessions every night, polling an external API every few minutes, or sending a one-off warm-up request shortly after boot. NestJS ships this capability through the @nestjs/schedule package, which wraps the battle-tested cron library and exposes it as clean, declarative decorators. Instead of wiring up timers by hand, you annotate provider methods and let the framework manage the lifecycle for you.
Installing and registering the module
The scheduling feature lives in a separate package. Install it alongside its peer dependency types and import ScheduleModule.forRoot() once, typically in your root module. Calling forRoot() bootstraps the scheduler and registers every declared job once the application has fully started.
npm install @nestjs/schedule
// app.module.ts
import { Module } from '@nestjs/common';
import { ScheduleModule } from '@nestjs/schedule';
import { TasksService } from './tasks.service';
@Module({
imports: [ScheduleModule.forRoot()],
providers: [TasksService],
})
export class AppModule {}
You only call
ScheduleModule.forRoot()a single time in the root module. The decorators are discovered automatically across all providers in the application, so feature modules just need their providers registered normally.
Declarative cron jobs with @Cron
The @Cron decorator runs a method on a standard cron schedule. You can pass a raw cron string or, better, one of the CronExpression presets, which keep your intent readable and free of off-by-one mistakes.
// tasks.service.ts
import { Injectable, Logger } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
@Injectable()
export class TasksService {
private readonly logger = new Logger(TasksService.name);
@Cron(CronExpression.EVERY_DAY_AT_MIDNIGHT)
handleNightlyCleanup() {
this.logger.log('Running nightly cleanup');
}
// Raw cron syntax: at second 45 of every minute
@Cron('45 * * * * *')
handleEveryMinuteAt45s() {
this.logger.debug('Triggered at the 45-second mark');
}
}
Output:
[Nest] 4821 - 06/14/2026, 12:00:00 AM LOG [TasksService] Running nightly cleanup
[Nest] 4821 - 06/14/2026, 12:01:45 AM DEBUG [TasksService] Triggered at the 45-second mark
NestJS cron expressions use six fields (with an optional leading seconds field): second minute hour day-of-month month day-of-week. The presets cover the common cases:
| Preset | Cron equivalent | Fires |
|---|---|---|
EVERY_10_SECONDS | */10 * * * * * | Every 10 seconds |
EVERY_MINUTE | * * * * * | Top of every minute |
EVERY_HOUR | 0 0-23/1 * * * | Top of every hour |
EVERY_DAY_AT_MIDNIGHT | 0 0 * * * | 00:00 daily |
EVERY_WEEK | 0 0 * * 0 | Sunday at midnight |
Intervals and timeouts
When you only need fixed spacing rather than calendar alignment, @Interval and @Timeout are simpler than crafting a cron string. @Interval repeats every N milliseconds; @Timeout fires exactly once, N milliseconds after the app starts.
import { Injectable, Logger } from '@nestjs/common';
import { Interval, Timeout } from '@nestjs/schedule';
@Injectable()
export class HeartbeatService {
private readonly logger = new Logger(HeartbeatService.name);
@Interval(10_000)
pingHealthCheck() {
this.logger.log('Heartbeat ping');
}
@Timeout(5_000)
warmUpCache() {
this.logger.log('Warming up cache after startup');
}
}
| Decorator | Argument | Behaviour |
|---|---|---|
@Cron | cron string / CronExpression | Runs on a calendar schedule |
@Interval | milliseconds | Repeats at a fixed delay |
@Timeout | milliseconds | Runs once after the delay |
Time zones
Cron jobs run in the server’s local time zone by default, which is fragile across deployments. Pass an options object with a timeZone (IANA identifier) to pin the schedule deterministically, so a “midnight” job means midnight where your business operates.
@Cron('0 0 * * *', {
name: 'eu-daily-report',
timeZone: 'Europe/Paris',
})
generateDailyReport() {
this.logger.log('Generating Paris-time daily report');
}
Always set an explicit
timeZonefor business-critical jobs. Relying on server local time leads to silent shifts when you move from a laptop to a cloud region in UTC, and breaks again twice a year around daylight saving transitions.
Named jobs
Giving a job a name lets you retrieve it later from the SchedulerRegistry to inspect, pause, or stop it at runtime. This is the foundation for dynamic scheduling.
import { Injectable, Logger } from '@nestjs/common';
import { Cron, SchedulerRegistry } from '@nestjs/schedule';
@Injectable()
export class ReportService {
private readonly logger = new Logger(ReportService.name);
constructor(private readonly schedulerRegistry: SchedulerRegistry) {}
@Cron('0 * * * *', { name: 'hourly-report' })
hourlyReport() {
this.logger.log('Hourly report generated');
}
stopReports() {
const job = this.schedulerRegistry.getCronJob('hourly-report');
job.stop();
this.logger.warn('Hourly report job stopped');
}
}
Output:
[Nest] 4821 - 06/14/2026, 1:00:00 PM LOG [ReportService] Hourly report generated
[Nest] 4821 - 06/14/2026, 1:05:12 PM WARN [ReportService] Hourly report job stopped
Best Practices
- Import
ScheduleModule.forRoot()exactly once in the root module and register task providers in their own feature modules. - Prefer
CronExpressionpresets over raw strings for readability, falling back to raw cron only when no preset fits. - Always set an explicit
timeZoneon schedule-sensitive jobs rather than trusting server local time. - Name long-lived jobs so you can manage them through the
SchedulerRegistrylater. - Keep handler bodies thin: enqueue heavy work to a queue (e.g. BullMQ) instead of doing CPU-bound processing inside the scheduler tick.
- Guard against overlapping runs for slow jobs, since the scheduler will still fire on the next tick even if the previous run has not finished.
- Wrap fallible work in try/catch and log failures, because an unhandled rejection inside a cron handler will not stop the schedule but can crash the process.