typescript 94 lines · 3 tabs

Cron scheduling with node-cron (with guard)

Shared by codesnips Jan 2026
3 tabs
type AsyncTask = () => Promise<void>;

export interface GuardOptions {
  name: string;
  logger?: Pick<Console, "info" | "warn" | "error">;
}

export function withRunGuard(task: AsyncTask, opts: GuardOptions): AsyncTask {
  const log = opts.logger ?? console;
  let running = false;

  return async function guardedRun(): Promise<void> {
    if (running) {
      log.warn(`[cron:${opts.name}] previous run still active, skipping tick`);
      return;
    }

    running = true;
    const startedAt = Date.now();
    try {
      await task();
      log.info(`[cron:${opts.name}] finished in ${Date.now() - startedAt}ms`);
    } catch (err) {
      log.error(`[cron:${opts.name}] run failed`, err);
    } finally {
      running = false;
    }
  };
}
3 files · typescript Explain with highlit

This snippet shows a common production problem with node-cron: a scheduled task can fire again before the previous run has finished, causing overlapping executions that double-process data or hammer a database. The fix is a guard that skips a tick when a previous run is still in flight, plus an optional cross-process lock for horizontally scaled deployments.

In job-guard.ts, withRunGuard wraps a task function and tracks in-process state with a plain boolean running. If a new tick arrives while running is true, the guard logs a skip and returns instead of launching a concurrent run. This closure-based approach is deliberately simple: a single Node process only needs an in-memory flag to serialize a recurring task. The try/finally block is the crucial detail — running is reset in finally so a thrown error never leaves the guard permanently stuck in the locked state, which would silently kill the schedule.

Because an in-memory flag only protects one process, distributed-lock.ts adds RedisLock for when the same cron runs on several instances. acquire uses Redis SET with NX (set only if absent) and PX (millisecond TTL) in one atomic call, so exactly one process wins the tick. The TTL matters: if a worker crashes mid-run the key expires on its own, preventing a permanently held lock. release runs a small Lua script that compares the stored token before deleting, so a slow worker whose lock already expired cannot delete a lock a different worker now holds.

In scheduler.ts, cron.schedule registers the expression and wires both guards together. Passing timezone makes 0 3 * * * mean 3am in a real zone rather than server-local time, a frequent source of off-by-hours bugs. validate rejects a malformed expression at startup instead of failing silently. The handler first checks the in-process guard, then the Redis lock, and always releases the lock in finally.

The trade-off is that skipped ticks are dropped, not queued — appropriate for idempotent maintenance work like cache warming or cleanup, but a real queue is better when every tick must eventually run. This pattern is the right reach when a periodic job's runtime is unpredictable and occasionally exceeds its interval.


Related snips

Share this code

Here's the card — post it anywhere.

Cron scheduling with node-cron (with guard) — share card
Link copied