# NestJS Scheduled Tasks: @Cron, Errors, and Multiple Instances

By Dan Ochoa · Published 2026-10-30 · Updated 2026-10-30
Canonical: https://cronradar.com/blog/nestjs-scheduled-tasks

> How @nestjs/schedule 12 runs @Cron, @Interval, and @Timeout, what happens to thrown errors, why every replica fires each job, and how to run it on one instance.

**`@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](https://www.npmjs.com/package/@nestjs/schedule/v/12.0.2) (checked October 30, 2026), which depends on cron 4.4.0 ([package.json](https://github.com/nestjs/schedule/blob/12.0.2/package.json)). The node-cron and node-schedule packages are covered in [Node.js cron monitoring](https://cronradar.com/blog/node-js-cron-monitoring). Queue-backed repeatable jobs are in [BullMQ repeatable jobs](https://cronradar.com/blog/bullmq-repeatable-jobs).

## Registering the Scheduler

Import `ScheduleModule.forRoot()` in the root module. The [task scheduling docs](https://docs.nestjs.com/application/task-scheduling) 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.

```typescript
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](https://github.com/nestjs/schedule/blob/12.0.2/lib/schedule.module.ts), [schedule-module-options.interface.ts](https://github.com/nestjs/schedule/blob/12.0.2/lib/interfaces/schedule-module-options.interface.ts)). With one set to `false`, the explorer skips that kind of handler ([schedule.explorer.ts](https://github.com/nestjs/schedule/blob/12.0.2/lib/schedule.explorer.ts)). The scheduler mounts timeouts, intervals, and cron jobs in `onApplicationBootstrap` and clears them in `beforeApplicationShutdown` ([scheduler.orchestrator.ts](https://github.com/nestjs/schedule/blob/12.0.2/lib/scheduler.orchestrator.ts)).

## @Cron, @Interval, and @Timeout

```typescript
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 JavaScript `Date`, or a Luxon `DateTime` ([cron.decorator.ts](https://github.com/nestjs/schedule/blob/12.0.2/lib/decorators/cron.decorator.ts)). A `Date` runs the method once at that moment. Cron strings have five fields, or six with seconds first ([docs](https://docs.nestjs.com/application/task-scheduling)).
- **`@Interval(name?, ms)`** calls `setInterval()`, and **`@Timeout(name?, ms)`** calls `setTimeout()` once, counted from application startup. The name is optional and only needed to reach the timer through `SchedulerRegistry`.

The `CronOptions` type at 12.0.2 defines these options ([cron.decorator.ts](https://github.com/nestjs/schedule/blob/12.0.2/lib/decorators/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](https://github.com/kelektiv/node-cron/blob/v4.4.0/src/time.ts), [README](https://github.com/kelektiv/node-cron/blob/v4.4.0/README.md)). For how that interacts with daylight saving, see [cron time zones](https://cronradar.com/blog/cron-timezone).

### 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](https://github.com/nestjs/schedule/blob/12.0.2/lib/enums/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](https://github.com/nestjs/schedule/blob/12.0.2/lib/schedule.explorer.ts)):

```typescript
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](https://github.com/nestjs/schedule/blob/12.0.2/lib/scheduler.registry.ts), [job.ts](https://github.com/kelektiv/node-cron/blob/v4.4.0/src/job.ts)).

### Two Ways a Tick Is Skipped Without an Error

Both happen inside cron 4.4.0 ([job.ts](https://github.com/kelektiv/node-cron/blob/v4.4.0/src/job.ts)):

- **`waitForCompletion: true`** returns 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](https://cronradar.com/blog/prevent-overlapping-cron-jobs).
- **A late tick past `threshold`** is dropped. When the timer fires more than 250 ms late (the option's doc comment in [cron.decorator.ts](https://github.com/nestjs/schedule/blob/12.0.2/lib/decorators/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](https://docs.nestjs.com/application/task-scheduling), [scheduler.registry.ts](https://github.com/nestjs/schedule/blob/12.0.2/lib/scheduler.registry.ts)):

```typescript
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](https://docs.nestjs.com/application/task-scheduling)). Each Kubernetes replica or cluster worker is its own process, so each one fires the job. [Distributed cron](https://cronradar.com/blog/distributed-cron) covers the general problem.

### Running a Job on One Instance

The documented answer is [`@nestjs/locks`](https://docs.nestjs.com/reliability/locks), at 0.0.5 on [npm](https://www.npmjs.com/package/@nestjs/locks/v/0.0.5) (checked October 30, 2026). Add `@OnOneInstance()` next to the `@Cron()`:

```typescript
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](https://docs.nestjs.com/reliability/locks) page documents:

- The instances share a lock store, such as your PostgreSQL database or Redis. The page shows how to register `LocksModule` and 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](https://cronradar.com/blog/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:

```typescript
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:

```typescript
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](https://docs.cronradar.com/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](https://cronradar.com/blog/cron-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](https://cronradar.com/blog/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.
