setInterval at the top of a service is the wrong tool for a scheduled job. It doesn't survive process restarts cleanly, doesn't integrate with NestJS's lifecycle, and makes testing a mess. @nestjs/schedule is the official NestJS scheduling package, and it 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.
This is the most common decorator. It 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 class ReportService {
private readonly logger = new Logger(ReportService.name);
@Cron("0 0 9 * * 1-5")
generateDailyReport() {
this.logger.log("Generating report...");
}
@Cron(CronExpression.EVERY_HOUR)
refreshCache() {
this.logger.log("Refreshing cache...");
}
}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 */3 * * * * | every 3 minutes |
0 */5 * * * * | every 5 minutes |
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_5_MINUTES;
CronExpression.EVERY_30_MINUTES;
CronExpression.EVERY_HOUR;
CronExpression.EVERY_DAY_AT_MIDNIGHT;
CronExpression.EVERY_WEEK;
// ...and moreThe enum only covers round intervals. There is no value for every 3 minutes, so pass the raw expression 0 */3 * * * * instead.
The decorator also accepts a name and a timeZone in its options object:
@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.
Use these two when you want 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.
The decorators above only cover schedules you know at build time. SchedulerRegistry is the injectable service that registers a dynamic cron job, then stops or removes it while the app is running.
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);
});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);
}
}A cron job that is not running at all is a different problem: ScheduleModule.forRoot() was never registered, or the service is not a provider in an imported module. On multiple instances, every replica fires its own copy of the schedule unless you add a lock.
If you're running jobs in production and want visibility, Sentry supports NestJS cron monitoring directly. It tracks whether a job ran, how long it took, and alerts on failures. It is a paid service, so a log line plus your existing alerting is often enough.
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, so 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.
Occasional notes on software, tools, and things I learn. No spam.
Unsubscribe anytime.