Scheduled Work
Timers, cron expressions and platform schedulers, and the lock that stops every copy of your service running the same job.
const won = await redis.set(`cron:${name}:${slot}`, replicaId, 'EX', 3600, 'NX') // 'OK' once, null for the restSome work runs on a clock rather than on a request: expire old sessions at two in the morning, email a summary on Mondays. Node ships no scheduler, so you pick one of three arrangements.
Timers, cron expressions, platform jobs
setInterval(run, 60_000) is the smallest. It is a plain timer: it knows nothing about the wall clock, it drifts, it starts counting again from each restart, and it stops existing when the process does. 60_000 and 60000 are the same number, and passing run is the same as passing () => run() for a function that takes no arguments.
A cron expression pins work to clock time instead. Five fields, in order minute, hour, day of month, month and day of week, so 0 2 * * * means 02:00 every day. Libraries such as node-cron read that expression inside your process and take a timezone option, which matters because servers usually run in UTC.
Third, the platform runs it for you. It starts a container, your program packaged with everything it needs, at each tick, runs it once and lets it exit. Kubernetes calls that a CronJob.
Every replica has a clock
A replica is one running copy of your service, and platforms run several so that one crash is not an outage. An in-process schedule fires in every one of them, so your nightly email goes out three times. Have one copy claim the slot first: SET cron:report:2026-01-05T02:00 replica-a NX EX 3600 stores the value only if the key is absent, so it returns OK to exactly one replica and null to the rest.
Example
When to use it
- Delete expired sessions once a night without a second service.
- Email a weekly summary at 02:30 local time, not server time.
- Stop three replicas from each sending the same nightly report.
- Refresh a cached exchange-rate table every fifteen minutes.
More examples
An interval that cannot overlap itself
Two gotchas in one block. An unhandled rejection inside a timer callback has no caller to catch it, and the overlap grows until the database is the bottleneck. unref() is the difference between a short script that exits and one that hangs.
// setInterval does not wait for an async callback to settle, so a run that
// takes longer than the interval overlaps the next one. A flag prevents that.
let running = false;
const timer = setInterval(async () => {
if (running) return; // the previous run has not finished
running = true;
try {
await expireOldSessions();
} catch (err) {
console.error({ msg: 'expiry failed', err: err.message }); // never throw out of here
} finally {
running = false; // runs on success and on failure
}
}, 60_000);
// unref() lets the process exit if nothing else is keeping it alive.
// Without it this timer alone keeps Node running for ever.
timer.unref();A cron expression, in the timezone you meant
Clock time is what the business asked for, and a timer cannot express it. The timezone option is the part people leave out: 02:30 on a UTC server is 03:30 in British summer time, and the report arrives an hour late for half the year.
import cron from 'node-cron';
// Fields: minute hour day-of-month month day-of-week
// 30 2 * * 1 -> 02:30 every Monday
cron.schedule(
'30 2 * * 1',
async () => {
await sendWeeklySummaries();
},
{ timezone: 'Europe/London' } // without this you get the server's zone, usually UTC
);
// A few expressions read as:
// 0 * * * * every hour, on the hour
// */15 * * * * every fifteen minutes
// 0 0 1 * * midnight on the first of the monthOne process per tick, instead of a scheduler in your service
The arrangement with the least to go wrong: no schedule inside the web service, nothing to deduplicate, and a failure the platform can see and report. The exit code is the whole interface, so set it deliberately and close your connections.
// tasks/expire-sessions.js
// Started by the platform's scheduler, runs once, exits. No timer, no lock:
// the platform guarantees one process per tick.
import { Pool } from 'pg';
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
try {
const { rowCount } = await pool.query(
"DELETE FROM sessions WHERE last_seen_at < now() - interval '30 days'"
);
console.log(JSON.stringify({ msg: 'sessions expired', rowCount }));
process.exitCode = 0; // success
} catch (err) {
console.error(JSON.stringify({ msg: 'expiry failed', err: err.message }));
process.exitCode = 1; // a non-zero exit is how the platform sees failure
} finally {
await pool.end(); // close the pool so the process can exit
}
Discussion