setInterval at the top of a service is the wrong tool. It doesn't survive process restarts cleanly, doesn't integrate with NestJS's lifecycle, and makes testing a mess. @nestjs/schedule is the right answer - it's the official NestJS scheduling package and takes about a minute to set up.
npm install @nestjs/scheduleRegister ScheduleModule in your root module:
import { ScheduleModule } from "@nestjs/schedule";
@Module({
imports: [ScheduleModule.forRoot()],
})
export class AppModule {}That's it. Now any injectable class can use the scheduling decorators.
The most common decorator. Takes a cron expression and runs the method on that schedule:
import { Injectable, Logger } from "@nestjs/common";
import { Cron, CronExpression } from "@nestjs/schedule";
@Injectable()
export
Occasional notes on software, tools, and things I learn. No spam.
Unsubscribe anytime.
NestJS uses 6-field cron expressions - the first field is seconds, which is different from the standard 5-field format you might know from Unix cron:
* * * * * *
│ │ │ │ │ └── day of week (0–7, 0 and 7 = Sunday)
│ │ │ │ └──── month (1–12)
│ │ │ └────── day of month (1–31)
│ │ └──────── hour (0–23)
│ └────────── minute (0–59)
└──────────── second (0–59)
Common patterns:
| Expression | Runs |
|---|---|
0 * * * * * | every minute |
0 0 * * * * | every hour |
0 0 0 * * * | every day at midnight |
0 0 9 * * 1-5 | weekdays at 9am |
0 */15 * * * * | every 15 minutes |
If you want to build or validate an expression interactively, the cron expression tool lets you do that without leaving the site.
If you don't want to memorise the syntax at all, CronExpression is an enum with the common cases already defined:
import { CronExpression } from "@nestjs/schedule";
CronExpression.EVERY_SECOND;
CronExpression.EVERY_MINUTE;
CronExpression.EVERY_HOUR;
CronExpression.EVERY_DAY_AT_MIDNIGHT;
CronExpression.EVERY_WEEK;
// ...and moreYou can also pass a name and timeZone in the options:
@Cron('0 0 9 * * 1-5', {
name: 'daily-report',
timeZone: 'Europe/London',
})
generateDailyReport() { ... }The name is required if you want to control the job later via SchedulerRegistry.
For millisecond-based scheduling rather than cron expressions:
import { Interval, Timeout } from "@nestjs/schedule";
@Injectable()
export class PollingService {
@Interval(5000)
pollExternalApi() {
// runs every 5 seconds
}
@Timeout(3000)
runOnStartup() {
// runs once, 3 seconds after the app starts
}
}@Timeout is useful when you want to run something shortly after startup - warming a cache, syncing initial state - without blocking the bootstrap process.
Most guides stop at static decorators. SchedulerRegistry is the part they skip - it lets you add, stop, and remove jobs at runtime.
import { Injectable } from "@nestjs/common";
import { SchedulerRegistry } from "@nestjs/schedule";
import { CronJob } from "cron";
@Injectable()
export class JobService {
constructor(private schedulerRegistry: SchedulerRegistry) {}
enableJob(name: string, cronExpression: string, callback: () => void) {
const job = new CronJob(cronExpression, callback);
this.schedulerRegistry.addCronJob(name, job);
job.start();
}
disableJob(name: string) {
const job = this.schedulerRegistry.getCronJob(name);
job.stop();
}
removeJob(name: string) {
this.schedulerRegistry.deleteCronJob(name);
}
}This is useful when a job should only run under certain conditions - a feature flag is on, a tenant has enabled it, or an admin toggles it via an API endpoint. You can also list all registered jobs:
const jobs = this.schedulerRegistry.getCronJobs();
jobs.forEach((job, name) => {
console.log(name, job.running);
});This is the part that bites most people.
Errors thrown inside a @Cron handler are silently swallowed. They don't reach NestJS exception filters. They don't show up in your logs. The job just fails and the next tick fires like nothing happened.
@Cron(CronExpression.EVERY_HOUR)
async refreshCache() {
// if this throws, you'll never know
await this.cache.refresh();
}Wrap every job body in a try/catch and log failures explicitly:
@Cron(CronExpression.EVERY_HOUR)
async refreshCache() {
try {
await this.cache.refresh();
} catch (err) {
this.logger.error('Cache refresh failed', err);
}
}If you're running jobs in production and want visibility, Sentry has first-class support for NestJS cron monitoring - it can track whether a job ran, how long it took, and alert on failures.
Scheduled jobs registered via decorators will start firing as soon as the test module boots. This causes test noise, timing issues, and occasionally flaky tests.
The simplest fix: don't import ScheduleModule in your test module. Without it, the decorators are inert - the methods exist but nothing schedules them.
const module = await Test.createTestingModule({
providers: [ReportService],
// ScheduleModule intentionally omitted
}).compile();If you need to test job behaviour specifically, or your service depends on SchedulerRegistry, mock it:
const module = await Test.createTestingModule({
providers: [
ReportService,
{ provide: SchedulerRegistry, useValue: { addCronJob: jest.fn() } },
],
}).compile();For more on structuring NestJS tests, this covers the patterns for unit and integration testing.