Scheduled Tasks
@nestjs/schedule runs a provider's method on a cron pattern or timer inside your process, which means every replica runs it.
@Cron(cronTime: string | Date, options?: { name?: string; timeZone?: string; waitForCompletion?: boolean; disabled?: boolean })@nestjs/schedule runs a method of a provider on a schedule: @Cron for a cron pattern, @Interval for "every N milliseconds", @Timeout for "once, N milliseconds after startup". It is a wrapper around the cron package and Node's own timers, not a separate service.
How it is wired
Import ScheduleModule.forRoot() once. At startup a scheduler explorer walks every provider Nest knows about, reads the metadata the decorators left behind, and creates the real timers in onApplicationBootstrap — after every module has initialised. A class that is not a provider in some module is never scanned, and nothing fires before bootstrap finishes.
The replica problem
These are ordinary in-process timers. Three pods means three ticks for the same cron, so a nightly invoice run emails every customer three times. Three ways out: have the tick only enqueue a queue job with a deterministic jobId, so the duplicates collapse; take a lock in a shared store, which is what @OnOneInstance() from the separate @nestjs/locks package does once you configure a store for it; or run the scheduler in exactly one replica.
Overlap
A run that outlasts the gap to the next tick overlaps itself by default. waitForCompletion: true skips the ticks that arrive while the previous run is still going.
Example
// src/reports/nightly.scheduler.ts
import { Injectable, Logger } from '@nestjs/common';
import { Cron } from '@nestjs/schedule';
import { InjectQueue } from '@nestjs/bullmq';
import { Queue } from 'bullmq';
Injectable()
export class NightlyScheduler {
private readonly logger = new Logger(NightlyScheduler.name);
constructor(InjectQueue('reports') private readonly reports: Queue) {}
// Six fields: second minute hour day-of-month month day-of-week. The
// seconds field is the optional one, so a five-field pattern starts at
// minutes. This is 03:15:00 in Berlin, whatever the server's clock says.
Cron('0 15 3 * * *', { name: 'nightly-report', timeZone: 'Europe/Berlin' })
async enqueueNightlyReport(): Promise<void> {
const day = new Date().toISOString().slice(0, 10); // 2026-10-03
// Every replica's timer fires, so this method runs on every replica. The
// deterministic jobId makes the extra adds no-ops: one job per day. The
// scheduled method stays fast, and the slow part is a retryable job.
await this.reports.add(
'daily-summary',
{ day },
{
jobId: `daily-summary-${day}`,
attempts: 3,
backoff: { type: 'exponential', delay: 30_000 },
removeOnComplete: false, // keep the id taken so re-adds stay no-ops
},
);
this.logger.log(`queued daily-summary for ${day}`);
}
}
// src/app.module.ts
import { Module } from '@nestjs/common';
import { BullModule } from '@nestjs/bullmq';
import { ScheduleModule } from '@nestjs/schedule';
import { ReportsModule } from './reports/reports.module';
Module({
imports: [
BullModule.forRoot({
connection: { host: process.env.REDIS_HOST ?? '127.0.0.1', port: 6379 },
}),
ScheduleModule.forRoot(), // exactly once in the whole application
ReportsModule,
],
})
export class AppModule {}When to use it
- Queue a nightly invoice export at 03:15 in the company's own timezone.
- Poll a partner's SFTP drop every five minutes without a second tick piling on.
- Let admins set a per-customer report hour and register it at runtime.
- Keep three API replicas from each sending the same daily digest.
More examples
Intervals, timeouts, and the overlap and disabled options
@Interval and @Timeout measure from application bootstrap, which makes them right for warm-ups and polling and wrong for "every night at three". waitForCompletion is the one-line fix for a sweep that occasionally takes longer than its interval, and disabled keeps timers out of your test runs without an if inside the method.
// src/health/queue-depth.monitor.ts
import { Injectable, Logger } from '@nestjs/common';
import { Cron, CronExpression, Interval, Timeout } from '@nestjs/schedule';
import { InjectQueue } from '@nestjs/bullmq';
import { Queue } from 'bullmq';
@Injectable()
export class QueueDepthMonitor {
private readonly logger = new Logger(QueueDepthMonitor.name);
constructor(@InjectQueue('reports') private readonly reports: Queue) {}
// Once, 10 seconds after bootstrap. Not at a wall-clock time.
@Timeout('warm-metrics', 10_000)
async warmMetrics(): Promise<void> {
await this.reportDepth();
}
// Every 30 seconds, counted from bootstrap. setInterval under the hood,
// so it drifts relative to the clock; @Cron does not.
@Interval('queue-depth', 30_000)
async reportDepth(): Promise<void> {
const counts = await this.reports.getJobCounts('waiting', 'active', 'failed');
this.logger.log(`reports queue: ${JSON.stringify(counts)}`);
}
// CronExpression.EVERY_5_MINUTES is shorthand for the string '0 */5 * * * *'.
// Both are the same argument; the enum just avoids a typo.
@Cron(CronExpression.EVERY_5_MINUTES, {
name: 'stale-sweep',
waitForCompletion: true, // a tick during a running sweep is skipped, not queued
disabled: process.env.NODE_ENV === 'test', // registered, never fired
})
async sweepStale(): Promise<void> {
// grace is in ms: remove failed jobs older than 30 days, 1000 at a time.
await this.reports.clean(30 * 24 * 60 * 60 * 1000, 1000, 'failed');
}
}Scheduling at runtime with SchedulerRegistry
Use the registry when the schedule is data, not code: a per-customer export time, a feature that admins can pause. The two details that cause support tickets are that addCronJob does not start the job and that getCronJob throws rather than returning undefined for an unknown name.
// src/reports/report-schedule.service.ts
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import { SchedulerRegistry } from '@nestjs/schedule';
import { CronJob } from 'cron'; // the package @nestjs/schedule is built on
@Injectable()
export class ReportScheduleService {
private readonly logger = new Logger(ReportScheduleService.name);
constructor(private readonly schedulerRegistry: SchedulerRegistry) {}
// The decorators only handle schedules known at compile time. Customer-chosen
// ones have to be built by hand and registered under a name.
addDailyExport(customerId: string, hour: number): void {
const name = `export-${customerId}`;
if (this.schedulerRegistry.doesExist('cron', name)) {
this.schedulerRegistry.deleteCronJob(name); // removes it from the registry
}
const job = new CronJob(`0 0 ${hour} * * *`, () => {
this.logger.log(`exporting for ${customerId}`);
});
this.schedulerRegistry.addCronJob(name, job);
job.start(); // addCronJob registers it; it does not start it for you
}
pause(name: string): void {
// Throws if nothing is registered under that name.
this.schedulerRegistry.getCronJob(name).stop();
}
listNextRuns(): { name: string; next: string }[] {
// getCronJobs returns a Map keyed by name; intervals and timeouts are
// listed as plain string[] by getIntervals() and getTimeouts().
return [...this.schedulerRegistry.getCronJobs().entries()].map(([name, job]) => ({
name,
next: job.nextDate().toISO() ?? 'unknown', // a Luxon DateTime
}));
}
}Running the scheduler in exactly one replica
The simplest answer to duplicate ticks, and the one to pick when a job is not naturally idempotent. The trade-off is a single point of failure for scheduling: while that replica is restarting, a tick in that window is lost, so pair it with jobs that can be re-triggered by hand.
// src/app.module.ts
import { Module, ModuleMetadata } from '@nestjs/common';
import { ScheduleModule } from '@nestjs/schedule';
import { BullModule } from '@nestjs/bullmq';
import { ReportsModule } from './reports/reports.module';
import { SchedulerModule } from './scheduler/scheduler.module';
// Deploy the same image twice: APP_ROLE=api with several replicas, and
// APP_ROLE=scheduler with exactly one. Only the second registers timers, so
// each cron pattern fires once no matter how far the API scales.
const schedulerImports: NonNullable<ModuleMetadata['imports']> =
process.env.APP_ROLE === 'scheduler'
? [ScheduleModule.forRoot(), SchedulerModule]
: [];
@Module({
imports: [
BullModule.forRoot({
connection: { host: process.env.REDIS_HOST ?? '127.0.0.1', port: 6379 },
}),
...schedulerImports,
ReportsModule, // queue producers and processors run in every role
],
})
export class AppModule {}
Discussion