• Node.js
  • Analytics
  • Databases

Node.js cron job: run a daily job once, on time, and catch up after downtime

Schedule a Node.js cron job that runs once a day: node-cron vs croner, UTC and DST, PM2 cluster duplicates, a MongoDB run lock, missed runs and monitoring.

Author
Extenify Team
Published
Reading time
12 min read
A round silver wall clock mounted on a pale hallway wall, with a long row of ceiling lights fading into the distance

A Node.js cron job looks like the easiest part of a backend: one line with 0 3 * * * and a function. Then production happens. The job runs twice because PM2 started four workers. It skips a day because the server restarted at 02:59. It runs at the wrong hour after a daylight-saving change. Or it silently stops, and nobody notices until a chart has a two-week gap in it.

Those are the questions developers keep asking: "CRON is running twice in Node js", "PM2: Node.js App with Cron Jobs ... Duplicate Cron Runs", and on Hacker News, "What do you use to monitor cron jobs and queue workers?" This guide builds a daily job the way an analytics backend needs it: collect one snapshot per day of installs, ratings or any metric, exactly once, in UTC, with a catch-up after downtime and an alert when it stops. The examples use Node.js 20 or newer, node-cron 4.6 and MongoDB, checked against each project's documentation on October 13, 2026.

Key takeaways

  • In-process schedulers such as node-cron and croner are fine for a daily job, as long as you add a run lock, a catch-up check and monitoring yourself.
  • Schedule in UTC. node-cron's docs say a daily time that falls in a daylight-saving gap is skipped for that day.
  • Every PM2 cluster worker schedules the job. Run it on one instance (NODE_APP_INSTANCE, node-cron's distributed option) and guard the work with a database lock keyed by the run date.
  • A process that is down at 03:00 does not run the 03:00 job later. Check for a missing run on startup and run it then.
  • Monitor the absence of success, not only errors: alert when the last successful run is more than about 26 hours old.

Where should a Node.js cron job live?

You have five realistic options. They fail in different ways, so pick based on what your job does.

Option Good for Watch out for
In-process (node-cron, croner) Small apps, one server, jobs that use app code and config Duplicates with several instances; no runs while the process is down
OS scheduler (cron, systemd timer) One server, jobs that can run as a script Lives outside your repo and deploy; easy to forget on a new server
Job queue (BullMQ and similar) Many jobs, retries, several workers Needs Redis and more moving parts
GitHub Actions schedule Jobs that only call APIs Minimum interval of 5 minutes, delays under load, runs only on the default branch
Cloud scheduler calling an endpoint Serverless apps An HTTP endpoint that must be protected

GitHub's workflow events documentation is candid about the trade-offs of the Actions route: "The shortest interval you can run scheduled workflows is once every 5 minutes," the event "can be delayed during periods of high loads," and in public repositories, "scheduled workflows are automatically disabled when no repository activity has occurred in 60 days." Fine for a weekly report; risky for the job that feeds your dashboard.

For a daily snapshot inside an existing Node.js server, the in-process route is usually the simplest, and the rest of this guide makes it safe.

Schedule a Node.js cron job with node-cron

node-cron 4 is a TypeScript rewrite that requires Node.js 20 or newer and has zero dependencies. A daily job at 03:10 UTC:

// scheduler.mjs (node-cron >= 4.4)
import cron from 'node-cron';
import { runDailySnapshot } from './daily-snapshot.mjs';

export function startScheduler() {
  const task = cron.schedule('10 3 * * *', () => runDailySnapshot('scheduled'), {
    name: 'daily-snapshot',
    timezone: 'UTC',
    noOverlap: true,
  });

  task.on('execution:failed', (ctx) => console.error('[daily-snapshot] failed', ctx.execution?.error));
  task.on('execution:missed', () => console.warn('[daily-snapshot] missed its slot'));
  console.log('[daily-snapshot] armed, next run', task.getNextRun()?.toISOString());
  return task;
}

The details that matter:

  • timezone: 'UTC'. Without it, node-cron uses the system timezone. Its timezone and DST page says that when clocks spring forward, a daily time inside the gap "is skipped for that day." UTC has no gaps, and daily metrics are easier to compare when every day has 24 hours.
  • Not on the hour. 10 3 * * * instead of 0 0 * * * keeps you away from the minute when every other job on the internet fires and APIs are busiest.
  • noOverlap: true. If a run takes longer than the interval, the next one is skipped and an execution:overlap event fires, instead of two runs stepping on each other.
  • One log line with the next run time. When someone asks "is the job even scheduled?", the answer is in the logs.

If you are upgrading from node-cron 3, the migration guide lists the breaking changes: the scheduled and runOnInit options are gone, tasks start when created, cron.createTask creates a stopped task, and task.execute() runs it immediately.

node-cron vs croner

croner is the other popular choice, with about 10 million weekly downloads on npm against roughly 5 million for node-cron in the week of October 6, 2026. Both handle timezones and prevent overlap (noOverlap in node-cron, protect in croner). One difference can bite you when you switch: when both day-of-month and day-of-week are set, node-cron combines them with AND, while croner's README says it uses OR by default, and offers domAndDow: true for AND. So 0 3 1 * MON means "the 1st, if it is a Monday" in node-cron and "every 1st and every Monday" in croner. For a plain daily job, either works.

Make sure the job runs once, not once per process

The most common bug is not the schedule but the number of processes. Under PM2 cluster mode with four workers, every worker imports your scheduler and every worker schedules the job. Same with two containers behind a load balancer, or a blue/green deploy where both colors are live for a minute.

Use two layers.

Layer 1: only one instance schedules the job. PM2 sets NODE_APP_INSTANCE in each process, and its environment documentation says "Two processes can never have the same number." Start the scheduler on instance 0 only:

// instrumentation or server start
const instance = Number(process.env.NODE_APP_INSTANCE ?? 0);
if (process.env.NODE_ENV === 'production' && instance === 0) startScheduler();

node-cron 4 also has this built in. With distributed: true and a name, its distributed coordination guide uses an environment variable, NODE_CRON_RUN, set to true on exactly one instance and false on the others, and it throws on startup if a distributed task finds the variable unset. For a fleet where any instance should be able to take over, it supports a Redis-backed run coordinator. The guide is honest about the limit: it guarantees no concurrent execution, but "it is not a hard exactly-once."

Layer 2: the work itself is guarded by a lock in your database. This is what makes a duplicate harmless, including a manual run from a script or a catch-up run racing a scheduled one. With MongoDB, a unique index on the job name and the UTC day does it:

// run-lock.mjs
import { MongoClient } from 'mongodb';

const client = new MongoClient(process.env.MONGODB_URI);
const runs = client.db().collection('job_runs');
await runs.createIndex({ job: 1, runKey: 1 }, { unique: true });

/** Returns true if this process claimed the run, false if another one already did. */
export async function claimRun(job, runKey) {
  try {
    await runs.insertOne({ job, runKey, status: 'running', startedAt: new Date() });
    return true;
  } catch (err) {
    if (err.code === 11000) return false; // duplicate key: already claimed
    throw err;
  }
}

export async function finishRun(job, runKey, status, details = {}) {
  await runs.updateOne({ job, runKey }, { $set: { status, finishedAt: new Date(), ...details } });
}

The runKey is the UTC date the run is for, such as 2026-10-13. Two processes can try; only one insert succeeds.

Catch up after downtime

An in-process scheduler only knows about the future. If the server is down from 02:50 to 03:30, the 03:10 run never happens, and node-cron will not run it later. Its missedExecutionTolerance option (1,000 ms by default) only covers a timer that wakes slightly late, not a process that was not running. System timers have a fix for this: systemd's Persistent=true stores the last trigger time on disk and runs a missed job when the timer starts again. In Node, you write the equivalent yourself with the run log you already have:

// daily-snapshot.mjs
import { claimRun, finishRun } from './run-lock.mjs';
import { collectSnapshots } from './collect.mjs';

const JOB = 'daily-snapshot';
const SCHEDULED_UTC = { hour: 3, minute: 10 };

function todayKey(now = new Date()) {
  return now.toISOString().slice(0, 10); // UTC day, e.g. 2026-10-13
}

export async function runDailySnapshot(reason) {
  const runKey = todayKey();
  if (!(await claimRun(JOB, runKey))) return; // already done or in progress elsewhere
  console.log(`[${JOB}] start ${runKey} (${reason})`);
  try {
    const result = await collectSnapshots(runKey);
    await finishRun(JOB, runKey, result.failed > 0 ? 'partial' : 'ok', result);
  } catch (err) {
    await finishRun(JOB, runKey, 'failed', { error: String(err) });
    throw err;
  }
}

/** Call once on startup, after the scheduler is armed. */
export async function catchUpIfMissed(now = new Date()) {
  const due = new Date(now);
  due.setUTCHours(SCHEDULED_UTC.hour, SCHEDULED_UTC.minute, 0, 0);
  if (now >= due) await runDailySnapshot('catch-up'); // the lock makes this a no-op if it already ran
}

Two decisions to make on purpose:

  • What a failed run means. In this sketch a failed run still holds the lock for the day, so it will not retry automatically. That is safe for jobs with side effects such as sending emails. For a pure data collection job, you may prefer to delete the failed run record and let the next catch-up try again.
  • How far back to catch up. One missed day is easy. For a week of downtime, decide whether you can backfill at all. Public store numbers such as weekly users or rating counts are a snapshot of now, so a missed day usually stays missed, and your charts should show it as a gap rather than invent a value.

Keep each run idempotent and bounded

A daily job that touches hundreds of items needs a few more guards so one slow API cannot sink the whole run:

  • Write per item, keyed by item and day. An upsert on { itemId, day } means a re-run overwrites instead of duplicating. If you store history in a MongoDB time series collection, which does not support upserts, delete the day's documents for that item and insert again, as our guide to MongoDB time series collections for extension analytics explains.
  • Time-box every request. fetch(url, { signal: AbortSignal.timeout(15_000) }) keeps a hung connection from holding the job open until the next day.
  • Limit concurrency. Ten requests at a time is polite to the API you call and predictable for your rate limits.
  • Retry per item, not per run. A failed item gets two or three retries with backoff, then lands in the run's failed count. Mark the run partial, not ok.
  • Compare after you store. Alerts come from the difference between today's snapshot and yesterday's, which is cheap once both are saved. Our post on JSON diff in Node.js shows that step.

Monitor the Node.js cron job

The failure you will not see in your error tracker is the job that never started. Errors need someone to throw them; a scheduler that was never armed throws nothing. So watch for the absence of success:

// check-heartbeat.mjs: run from a different process or machine than the job.
// `runs` is the job_runs collection from run-lock.mjs; `notify` is your alert sender.
const last = await runs.findOne({ job: 'daily-snapshot', status: { $in: ['ok', 'partial'] } }, { sort: { finishedAt: -1 } });
const ageHours = last ? (Date.now() - last.finishedAt.getTime()) / 3_600_000 : Infinity;
if (ageHours > 26) {
  await notify(`daily-snapshot has not succeeded for ${Math.round(ageHours)} hours`);
}

Run the check from somewhere else: a second server, an uptime service that hits a health endpoint returning the last success time, or even a scheduled GitHub Action, since a few minutes of delay does not matter here. Send it to a channel people read, such as a Telegram bot or an email through Amazon SES in Node.js. Then log three things on every run: the start, the end with counts (ok, partial, failed), and the next scheduled time.

If you prefer a systemd timer

On a single Linux server, a systemd timer gives you persistence for free. A minimal pair of units:

# /etc/systemd/system/daily-snapshot.service
[Unit]
Description=Collect the daily store snapshot

[Service]
Type=oneshot
WorkingDirectory=/srv/app
ExecStart=/usr/bin/node scripts/daily-snapshot.mjs
# /etc/systemd/system/daily-snapshot.timer
[Unit]
Description=Run the daily snapshot at 03:10 UTC

[Timer]
OnCalendar=*-*-* 03:10:00 UTC
Persistent=true

[Install]
WantedBy=timers.target

Enable it with systemctl enable --now daily-snapshot.timer and check the next run with systemctl list-timers. Keep the same database lock inside the script: persistence protects against downtime, not against a second server with the same timer. The trade-off is the one in the table above. The schedule lives on the machine, not in your repository, so a rebuilt server or a new deploy target can lose it. If your deploys are git pull and a restart, an in-process scheduler that ships with the code is harder to lose.

Or skip the snapshot job entirely

If the daily job you are building collects store data, such as weekly users, ratings, review counts and versions for your extension or app and its competitors, Extenify already runs it. It is a chrome extension analytics and extension install tracking dashboard that records a daily snapshot of your extension's users, ratings and versions on the Chrome Web Store, Edge Add-ons, Firefox Add-ons, Google Play and the App Store, compares each day with the day before, and sends change alerts by email, Slack, Discord, Telegram or Microsoft Teams. Our guide to the Chrome Web Store stats API explains what those public numbers mean, and our post on Chrome extension user growth shows what to do with the history once you have it. Every account starts with a 7-day free trial, and the Extenify pricing page lists the plans.

Frequently asked questions

How do I run a cron job every day at midnight in Node.js?

With node-cron, cron.schedule('0 0 * * *', job, { timezone: 'UTC' }) runs at 00:00 UTC every day. Use a specific timezone if you need local midnight, and consider a time a few minutes past the hour to avoid busy APIs. Add a database lock and a startup catch-up check if a missed or duplicated run would matter.

Why is my node-cron job running twice?

Usually because two processes scheduled it: PM2 cluster mode, several containers, a deploy overlap, or code that calls cron.schedule twice during hot reload in development. Schedule it on one instance only (for example NODE_APP_INSTANCE === '0' under PM2, or node-cron's distributed option) and guard the work with a unique run key in your database.

Does node-cron run missed jobs after a restart?

No. node-cron does not persist state, so a run that was due while the process was down is not executed later. Its missedExecutionTolerance option only covers timers that wake slightly late. Record each run in your database and check on startup whether today's run is missing.

Should I use node-cron, croner or BullMQ?

For a few scheduled jobs inside one Node.js app, node-cron or croner is enough. Choose croner if you want its protect and catch options, and remember its day-of-month and day-of-week rules default to OR. Move to a queue such as BullMQ when you need durable retries, many jobs or several workers sharing the load.

How do I set the timezone for a Node.js cron job?

Pass an IANA timezone name: { timezone: 'UTC' } in node-cron or croner. Without it, both use the server's system timezone, which can differ between your laptop, CI and production. For daily metrics, UTC avoids daylight-saving gaps and repeated hours.

Summary

A reliable Node.js cron job for daily data needs four things beyond the schedule: UTC so every day has 24 hours, one scheduler across all your processes, a database lock keyed by the run date so duplicates do nothing, and a startup check that runs the day's job if the process was down at the scheduled time. Keep each run idempotent, time-boxed and per-item, then monitor the age of the last successful run from outside the process. That is the difference between a chart with data every day and one with gaps nobody explained.

Image credits

Put every store in one dashboard tonight.

Sign in with Google, paste one store link, and your unified view is live in under a minute. Then 7 days of Pro free, no credit card required, and you never touch your extension's code.

Start free trial