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/schedule | cron | Stops on negative timeout |
|---|---|---|
| 4.1.2 | 3.2.1 | yes |
| 5.0.0, 5.0.1 | 3.5.0 | yes |
| 6.0.0 | 4.3.0 | no |
| 6.0.1 | 4.3.3 | no |
| 6.1.1 and later, 12.x | 4.4.0 | no |
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
- Why APScheduler Silently Stops Running Your Python Jobs
- BullMQ v6 Dropped Repeatable Jobs: Your Recurring Jobs Will Stop
- Dead Man's Switch Monitoring: The Only Reliable Way to Watch Cron Jobs
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.