Back to blog

NestJS @Cron Jobs That Stop for Good: The cron 3.x Timer Bug

A timing bug in the cron package behind @nestjs/schedule unschedules jobs with no error. 40% of last week's cron downloads were affected versions. How to check.

CronGuard TeamCron Job Monitoring Experts
6 min read
A code editor on a dark screen at an angle, with a project file tree on the left listing node_modules, pages, package.json and tsconfig.json, and TypeScript imports and a React component open in the main pane

A scheduler that stops and says nothing

The service is up and the health check is green. The @Cron job that should run every five minutes last fired on Tuesday.

PostyBirb reported exactly that on 17 October 2026. The cause is the cron npm package behind every @nestjs/schedule @Cron decorator. In the 3.x line, one unlucky millisecond unschedules a job for good. Nothing throws.

What PostyBirb found in October

PostyBirb issue #1047 describes scheduled posts that stopped firing after the app had been up for several days. A restart brought them back.

The app pinned @nestjs/schedule 4.1.2, which pulls in cron 3.2.1. The one clue in the logs was a Node TimeoutNegativeWarning shortly after the last successful post.

How cron 3.x unschedules a job

The clock gets read twice

After every tick the job re-arms itself by calling start() again. In 3.2.1, start() gets its delay from this line in src/time.ts:

getTimeout() {
  return Math.max(-1, this.sendAt().toMillis() - DateTime.local().toMillis());
}

sendAt() reads the clock to find the next run. DateTime.local() reads it again. If enough time slips between those two reads, the result goes negative. A GC pause or a busy event loop will do it. The upstream report, issue #962 from February 2025, has jobs dying anywhere from 15 minutes to several days after start.

A negative timeout means stop()

Here is what start() in src/job.ts does with that number:

if (timeout >= 0) {
  this.running = true;
  // ... setTimeout(callbackWrapper, timeout)
} else {
  this.stop();
}

stop() clears the timer and sets running to false. It schedules nothing. The job is dead until the process restarts.

Why the log stays quiet

stop() logs nothing. The error tracker has no error to see, and the uptime check is happy because the HTTP server is fine.

Which versions are affected

Every cron release before 4.3.0 has that stop() branch. We read the source of six releases from 3.2.1 to 4.2.0 to confirm it.

The exact pins in @nestjs/schedule

@nestjs/schedule pins cron to one exact version, so npm update will not help. Only a newer @nestjs/schedule brings a fixed cron.

@nestjs/schedulecronStops on negative timeout
4.1.23.2.1yes
5.0.0, 5.0.13.5.0yes
6.0.04.3.0no
6.0.14.3.3no
6.1.1 and later, 12.x4.4.0no

PostyBirb's write-up names cron 4.4.0 as the fix. That works, but the changelog and the source put it earlier: 4.3.0, released 15 April 2025.

NestJS 8 and 9 have no fixed release

@nestjs/schedule 4.1.2 is the last release that accepts NestJS 8 or 9 as a peer. Everything after it wants NestJS 10 or 11, so older apps get no fix from the scheduler package.

How many installs are still exposed

npm counted 8,389,301 downloads of cron in the week up to 29 October 2026. 3,387,112 of them, about 40 percent, were versions older than 4.3.0. @nestjs/schedule 4.1.2 alone had 815,665. Downloads are a rough proxy for deployments, but that is a lot of builds installing a timer that can switch itself off.

What cron 4.3 changed

The fix swapped stop() for a threshold option, 250 ms by default. The job now always re-arms.

Late runs inside the threshold

A deadline missed by 250 ms or less runs immediately and logs a warning starting with [Cron] Missed execution deadline. Fine for nearly every job.

Skipped runs beyond the threshold

Anything later gets skipped with a console.warn. A nightly export that loses its slot to a 400 ms event-loop stall is gone until tomorrow. From 6.0.1 on, @Cron accepts a threshold option, so raise it where a late run beats no run.

Fixing and catching it

Check what you actually run

npm ls cron @nestjs/schedule

Any cron below 4.3.0 in that tree is affected, including copies other packages pull in.

Upgrade, and mind the rename

On NestJS 10 or 11, move to @nestjs/schedule 6.1.1 or later. cron 4.0.0 renamed job.running to job.isActive, so code that reads the old name breaks.

A watchdog for jobs you cannot upgrade yet

Stuck on 3.x? Restart stopped jobs from a different timer. @Interval uses a plain setInterval, which the bug does not touch:

import { Injectable, Logger } from '@nestjs/common';
import { Interval, SchedulerRegistry } from '@nestjs/schedule';

@Injectable()
export class CronWatchdog {
  private readonly logger = new Logger(CronWatchdog.name);
  private readonly disabledOnPurpose = new Set<string>(['legacy-sync']);

  constructor(private readonly registry: SchedulerRegistry) {}

  @Interval(60_000)
  check() {
    for (const [name, job] of this.registry.getCronJobs()) {
      if (!job.running && !this.disabledOnPurpose.has(name)) {
        this.logger.error(`cron job ${name} was unscheduled, restarting`);
        job.start();
      }
    }
  }
}

Keep jobs declared with disabled: true out of the check, or the watchdog switches them on.

A heartbeat from outside the process

The watchdog lives in the process it watches, so a skipped run or a hung event loop is invisible to it. Ping a monitor at the end of each successful run and alert when the ping is missing:

@Cron('0 3 * * *', { name: 'nightly-export' })
async nightlyExport() {
  await this.exportService.run();
  await fetch(process.env.NIGHTLY_EXPORT_PING_URL!);
}

That is the dead man's switch pattern. Why the run went missing is a question for later.

Frequently asked questions about NestJS cron jobs that stop

Why do NestJS cron jobs stop running without an error? Before version 4.3.0, the cron package re-arms a job after every tick by computing the delay to the next run. If that delay comes out negative, it calls stop() and logs nothing. The process keeps serving requests, so nothing looks wrong.

Which versions of @nestjs/schedule are affected? Version 4.1.2 pins cron 3.2.1, and 5.0.0 and 5.0.1 pin cron 3.5.0. Both can stop a job permanently. Version 6.0.0 ships cron 4.3.0, the first release without the bug, and since the pin is exact, npm update will not pull in a fix.

Does upgrading to cron 4.3 mean every run is executed? No. A run missed by up to the threshold, 250 ms by default, executes immediately with a warning. A run missed by more is skipped with a console warning, so a stalled event loop can still cost you a run.

How can I tell whether one of my jobs has already stopped? List the jobs from SchedulerRegistry and check the running flag on cron 3.x, or isActive on cron 4.x. A job you did not disable that reports false has been unscheduled.

Is an in-process watchdog enough to monitor scheduled jobs? No. A watchdog in the same Node process can restart unscheduled jobs, but it cannot see skipped runs or a hung event loop. A heartbeat sent after each successful run and checked from outside the process catches those too.

Further reading


Conclusion: Until 4.3.0, one negative timeout was enough for the cron package to drop a NestJS job without a word, and 40 percent of its downloads are still older than that. Upgrading removes the permanent stop but not the skipped run, so the check that matters lives outside the process.

Sources: PostyBirb issue #1047, node-cron issue #962, node-cron changelog, cron on npm.

Share

Related posts

A dark monitor filled edge to edge with dense, colour-highlighted minified JavaScript, shot at an angle so the right side fades into black
ReliabilityCloudflare Cron Triggers Broke for Schedules You Had Just Changed
Rows of patch panels in a server rack with looped black network cables, lit in deep green and half hidden behind the dark edge of a neighbouring cabinet
ReliabilityGang-Scheduled CronJobs in Kubernetes 1.37: The Run That Never Starts
A fiber-optic patch panel in a rack, green connectors and yellow cables looping across rows of numbered ports
ReliabilityWhen a Scheduled Job Runs Long: Overlap, Skip, or Replace

Set up your first monitor.
It'll take 30 seconds.