Some work does not belong in a request: rebuilding a search index, expiring sessions, mailing a nightly digest. node-cron 3,280 (ISC, 4.6.0, github.com/node-cron/node-cron (https://github.com/node-cron/node-cron 3,280 ), npm 2,036 i node-cron) runs it on a cron expression inside your process, with no external scheduler. Version 4 accepts six fields, so the leading one is seconds; five fields still mean the classic minute-first form.
import cron from 'node-cron';
let runs = 0;
const task = cron.schedule('*/2 * * * * *', async (ctx) => {
console.log(`run ${++runs} triggered ${ctx.triggeredAt.toISOString().slice(11, 19)}`);
if (runs !== 3) return;
console.log('next run:', task.getNextRun()?.toISOString().slice(11, 19));
await task.stop();
console.log('task status:', task.getStatus());
}, { name: 'reindex', timezone: 'UTC', noOverlap: true });
console.log('valid:', cron.validate('0 3 * * *'), '| tasks:', cron.getTasks().size);valid: true | tasks: 1 next run: 12:20:04 run 1 triggered 12:20:04 run 2 triggered 12:20:06 run 3 triggered 12:20:08 next run: 12:20:10 task status: stopped
Four options carry most of the weight. timezone pins the schedule to wall-clock time in a named zone, so "03:00" survives daylight saving instead of drifting an hour twice a year. noOverlap skips a tick while the previous run is still going, the difference between a slow job and a pile of slow jobs. maxExecutions stops a task after a fixed count, and maxRandomDelay spreads a fleet of identical instances over a window so they do not all hit the database at 03:00:00. Tasks also expose start(), destroy(), execute() and lastRun().
The catch is that in-process scheduling multiplies with your processes: four cluster workers means four nightly digests. Either run the scheduler as its own single instance, or take a lock — SET schedule:digest <id> NX EX 3600 in Redis 2,763 — as the first thing the job does. Keep jobs short and idempotent; anything that takes minutes belongs in the queue of BullMQ Queues.