Scheduled Work

Timers, cron expressions and platform schedulers, and the lock that stops every copy of your service running the same job.

Syntaxconst won = await redis.set(`cron:${name}:${slot}`, replicaId, 'EX', 3600, 'NX') // 'OK' once, null for the rest

Some 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

Try it yourself
Loading editor…
Press Run to execute the code.

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.

Example · javascript
// 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.

Example · javascript
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 month

One 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.

Example · javascript
// 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

  • Be the first to comment on this lesson.