NestJS Scheduled Tasks: @Cron, Errors, and Multiple Instances
Contents
@nestjs/schedule runs @Cron(), @Interval(), and @Timeout() methods inside your application process, and it wraps each one in a try/catch that logs the error and keeps the schedule going. Every running instance of the app fires every job. To run a job once per tick across replicas, the NestJS docs point to @OnOneInstance() from @nestjs/locks.
This guide covers @nestjs/schedule 12.0.2, the current release on npm (checked October 30, 2026), which depends on cron 4.4.0 (package.json). The node-cron and node-schedule packages are covered in Node.js cron monitoring. Queue-backed repeatable jobs are in BullMQ repeatable jobs.
Registering the Scheduler#
Import ScheduleModule.forRoot() in the root module. The task scheduling docs say to call forRoot() in one module only: each additional import registers every @Cron(), @Interval(), and @Timeout() handler again, so each job runs once per import.
import { Module } from '@nestjs/common'
import { ScheduleModule } from '@nestjs/schedule'
@Module({
imports: [ScheduleModule.forRoot()],
})
export class AppModule {}forRoot() accepts three booleans, cronJobs, intervals, and timeouts, all true by default (schedule.module.ts, schedule-module-options.interface.ts). With one set to false, the explorer skips that kind of handler (schedule.explorer.ts). The scheduler mounts timeouts, intervals, and cron jobs in onApplicationBootstrap and clears them in beforeApplicationShutdown (scheduler.orchestrator.ts).
@Cron, @Interval, and @Timeout#
import { Injectable, Logger } from '@nestjs/common'
import { Cron, CronExpression, Interval, Timeout } from '@nestjs/schedule'
@Injectable()
export class TasksService {
private readonly logger = new Logger(TasksService.name)
@Cron(CronExpression.EVERY_DAY_AT_2AM, {
name: 'nightly-report',
timeZone: 'Europe/Paris',
waitForCompletion: true,
})
async nightlyReport() {
this.logger.log('Building the report')
}
@Interval('cache-refresh', 10000)
refreshCache() {}
@Timeout(5000)
warmUp() {}
}@Cron(cronTime, options)takes a cron string, a JavaScriptDate, or a LuxonDateTime(cron.decorator.ts). ADateruns the method once at that moment. Cron strings have five fields, or six with seconds first (docs).@Interval(name?, ms)callssetInterval(), and@Timeout(name?, ms)callssetTimeout()once, counted from application startup. The name is optional and only needed to reach the timer throughSchedulerRegistry.
The CronOptions type at 12.0.2 defines these options (cron.decorator.ts):
| Option | What it does |
|---|---|
name |
Registers the job under this name in SchedulerRegistry. Without it, the job gets a random UUID. |
timeZone |
Evaluates the expression in this time zone. An invalid zone throws. |
utcOffset |
A fixed offset instead of timeZone. The type makes the two mutually exclusive. |
waitForCompletion |
If true, a tick that arrives while the previous run is still going is skipped entirely. |
disabled |
If true, the job is registered but never started. Defaults to false. |
threshold |
How late, in milliseconds, a tick may fire before it is skipped instead of run. Defaults to 250. |
initialDelay |
Milliseconds to wait after bootstrap before the job starts. Later runs follow the schedule. |
unrefTimeout |
Lets the process exit even though the job's timer is pending. |
Without a timeZone or utcOffset, cron 4.4.0 uses the system time zone (time.ts, README). For how that interacts with daylight saving, see cron time zones.
CronExpression Mixes Five- and Six-Field Strings#
The enum is a set of string constants, and they do not share one format (cron-expression.enum.ts):
| Constant | Value | Fields |
|---|---|---|
EVERY_30_SECONDS |
*/30 * * * * * |
6 |
EVERY_MINUTE |
*/1 * * * * |
5 |
EVERY_5_MINUTES |
0 */5 * * * * |
6 |
EVERY_HOUR |
0 0-23/1 * * * |
5 |
EVERY_DAY_AT_2AM |
0 02 * * * |
5 |
EVERY_DAY_AT_MIDNIGHT |
0 0 * * * |
5 |
MONDAY_TO_FRIDAY_AT_1AM |
0 0 01 * * 1-5 |
6 |
Both forms run correctly in Nest. The difference matters when you copy the schedule into another tool that expects five fields, such as a monitor.
What Happens When a Handler Throws#
The explorer wraps every declarative @Cron(), @Interval(), and @Timeout() method before it is scheduled (schedule.explorer.ts):
private wrapFunctionInTryCatchBlocks(methodRef: Function, instance: object) {
return async (...args: unknown[]) => {
try {
await methodRef.call(instance, ...args);
} catch (error) {
this.logger.error(error);
}
};
}Because the method is awaited, both a synchronous throw and a rejected promise land in the catch. The error is logged through Nest's Logger and goes nowhere else: the process keeps running, the job stays scheduled, and the next tick runs as usual. Nothing outside the log records that the run failed.
Jobs added with SchedulerRegistry.addCronJob() also get a Nest try/catch, but it wraps the CronJob's fireOnTick, not your callback. In cron 4.4.0, fireOnTick catches callback errors first and sends them to the job's errorHandler if one is set, or to console.error('[Cron] error in callback', error) otherwise, so they do not reach Nest's Logger (scheduler.registry.ts, job.ts).
Two Ways a Tick Is Skipped Without an Error#
Both happen inside cron 4.4.0 (job.ts):
waitForCompletion: truereturns early from a tick while the previous run is in progress. Without it, an async handler's promise is not awaited, so a slow run and the next tick overlap. The flag only knows about runs in its own process. For overlap in general, see prevent overlapping cron jobs.- A late tick past
thresholdis dropped. When the timer fires more than 250 ms late (the option's doc comment in cron.decorator.ts blames slow or busy hardware), cron logs a warning ending in "Skipping execution as it exceeds threshold (250ms)." and does not run the job. Within the threshold, it logs a warning and runs it immediately.
SchedulerRegistry#
Inject SchedulerRegistry to control named jobs at runtime (docs, scheduler.registry.ts):
import { Injectable } from '@nestjs/common'
import { SchedulerRegistry } from '@nestjs/schedule'
import { CronJob } from 'cron'
@Injectable()
export class JobsAdmin {
constructor(private readonly schedulerRegistry: SchedulerRegistry) {}
pauseReport() {
const job = this.schedulerRegistry.getCronJob('nightly-report')
job.stop()
return job.lastDate() // Date of the last run, or null
}
addCleanup() {
const job = new CronJob('0 3 * * *', () => {
// errors here go to cron's console.error, not Nest's Logger
})
this.schedulerRegistry.addCronJob('cleanup', job)
job.start()
}
}getCronJobs() returns a Map of name to CronJob. nextDate() returns a Luxon DateTime and throws if a job has no future run. Intervals and timeouts have matching getInterval, addInterval, deleteInterval, getTimeout, addTimeout, and deleteTimeout methods.
Every Replica Runs Every @Cron#
The scheduler is an in-process timer, so there is no shared state between instances. The NestJS docs state it directly: "The scheduler runs every job in every process of your application." With three instances behind a load balancer, a @Cron('0 2 * * *') job runs three times a night (task scheduling docs). Each Kubernetes replica or cluster worker is its own process, so each one fires the job. Distributed cron covers the general problem.
Running a Job on One Instance#
The documented answer is @nestjs/locks, at 0.0.5 on npm (checked October 30, 2026). Add @OnOneInstance() next to the @Cron():
import { Injectable } from '@nestjs/common'
import { OnOneInstance } from '@nestjs/locks'
import { Cron } from '@nestjs/schedule'
@Injectable()
export class InvoicesJob {
@Cron('0 2 * * *')
@OnOneInstance({ key: 'invoices:nightly-export' })
async exportInvoices() {
// ...
}
}What the Distributed locks page documents:
- The instances share a lock store, such as your PostgreSQL database or Redis. The page shows how to register
LocksModuleand the store. - The first instance to fire the job takes a lease and keeps renewing it. On the other instances, the call resolves without running the method.
- When the owner dies, its lease expires after
ttl(default'30s'), and the next instance to tick takes the job over. Ticks during that window are skipped, and "Missed ticks aren't caught up". @WithoutOverlapping()skips a tick while a previous run is still going on any instance.
The same page explains why the usual fixes fall short. A single RUN_JOBS=true instance "works until a rolling deploy has two of them for a minute", and waitForCompletion: true "stops a job from overlapping itself, on one instance". Turning jobs off with forRoot({ cronJobs: false }) on all but one instance is the same flag approach. Either way, write the job so a second run does no harm, as in idempotent scheduled jobs.
Reporting Each Run to CronRadar#
The scheduler logs errors and keeps going, and a skipped or missed tick logs at most a warning. An external check-in turns each run into a record, and a missing record into an alert.
The CronRadar Node.js SDK is temporarily unavailable: the published package cannot load because it is missing a required file. Report over HTTP instead. HTTP check-ins do not discover jobs automatically, so each @Cron() method needs its own monitor key and its schedule.
This helper uses the built-in fetch with a five-second timeout and Basic authentication, with the API key as the username and an empty password. Monitoring errors are logged and never change the job's result. The job's own error is rethrown, so Nest's wrapper still logs it:
import { randomUUID } from 'node:crypto'
const CRONRADAR_PING_URL = 'https://cron.life/api/v1/ping'
async function report(
monitorKey: string,
event: 'start' | 'complete' | 'fail',
params: Record<string, string>,
): Promise<void> {
const apiKey = process.env.CRONRADAR_API_KEY
if (!apiKey) return
const query = new URLSearchParams(params).toString()
const url = `${CRONRADAR_PING_URL}/${encodeURIComponent(monitorKey)}/${event}?${query}`
try {
const response = await fetch(url, {
method: 'POST',
headers: { Authorization: `Basic ${Buffer.from(`${apiKey}:`).toString('base64')}` },
signal: AbortSignal.timeout(5000),
})
if (!response.ok) console.error(`CronRadar returned ${response.status} for ${monitorKey}`)
} catch (error) {
console.error(`CronRadar ${event} report failed for ${monitorKey}: ${(error as Error).message}`)
}
}
export async function monitored(
monitorKey: string,
schedule: string,
task: () => Promise<void>,
): Promise<void> {
const runId = randomUUID()
await report(monitorKey, 'start', { schedule, run_id: runId })
try {
await task()
} catch (error) {
// Keep the message generic: error details can contain data that must not leave the app.
await report(monitorKey, 'fail', {
run_id: runId,
message: `${monitorKey} threw ${(error as Error).name}`,
})
throw error
}
await report(monitorKey, 'complete', { run_id: runId })
}Call it inside the method body, after the decorators:
import { Injectable } from '@nestjs/common'
import { OnOneInstance } from '@nestjs/locks'
import { Cron } from '@nestjs/schedule'
import { monitored } from './cronradar'
@Injectable()
export class InvoicesJob {
@Cron('0 2 * * *', { name: 'invoice-export', timeZone: 'UTC' })
@OnOneInstance({ key: 'invoices:nightly-export' })
async exportInvoices() {
await monitored('invoice-export', '0 2 * * *', () => this.runExport())
}
private async runExport() {
// ...
}
}Because @OnOneInstance() skips the method on instances that do not hold the lease, only the instance that runs the job reports it.
CronRadar reads schedules as standard five-field cron and evaluates them in UTC (schedules). Run the job in UTC and send the same expression. For a six-field CronExpression constant, drop the seconds field: 0 */5 * * * * becomes */5 * * * *. A job that runs more than once a minute, such as EVERY_30_SECONDS, has no five-field form.
The start report carries the schedule, so CronRadar creates the monitor the first time it sees the key. A fail report opens an incident immediately. A start without a completion, and a run that never arrives, are caught against the schedule, which covers the thrown error Nest only logs, the skipped tick, and the instance that stopped. Alerts go to email, Slack, webhook, Discord, or PagerDuty. Keep CRONRADAR_API_KEY in your deployment's secret store or an environment file that is not committed, and use a separate CronRadar application for each environment. Set the grace period to cover the job's usual duration.
Frequently Asked Questions#
Why does my NestJS cron job run twice?#
Either more than one process is running the app, or ScheduleModule.forRoot() is imported in more than one module. The scheduler runs every job in every process, and each extra forRoot() import registers every handler again. Cron job running twice covers the other causes.
Does an exception in a @Cron method crash the app?#
No. Declarative @Cron(), @Interval(), and @Timeout() methods are wrapped in a try/catch that logs the error through Nest's Logger. The job stays scheduled, and nothing outside the log records the failure.
Does waitForCompletion work across instances?#
No. It skips a tick only while the same CronJob, in the same process, is still running its previous callback. Across instances, use @WithoutOverlapping() from @nestjs/locks.
What time zone does @Cron use by default?#
The system time zone of the process. Pass timeZone (an IANA name such as 'Europe/Paris') or utcOffset, but not both.
Can CronRadar discover NestJS jobs automatically?#
No. The Node.js SDK is temporarily unavailable, and HTTP check-ins do not discover jobs. Report each job with its own monitor key and five-field UTC schedule.
Put this guide to work
Connect your Node.js job to monitoring
Use the working HTTP reporting path in the guide while the SDK is unavailable. This requires explicit instrumentation and does not discover jobs automatically.
The Node.js SDK is temporarily unavailable: the published package cannot load because it is missing a required file. Use manual HTTP reporting below. This setup does not discover jobs automatically.