Migrating production infrastructure? Get up to ₹10,000 in migration credits. Apply now
voroaBlog
Background Workers

Cron Job in Node.js: node-cron Tested for Duplicate Runs

· · 7 min read

A cron job in Node.js with node-cron, tested for duplicate runs, overlapping jobs and missed runs

A cron job in Node.js runs a function on a schedule: a nightly report, an hourly cleanup, a reminder email every morning. The usual way is the node-cron package, and it takes five lines.

This guide shows those lines, then what we saw when we tested the parts tutorials skip. Two app instances, a slow job and a restart each changed the result.

On this page

Advertisement

Set up node-cron in five lines

bash
npm install node-cron
js
import cron from 'node-cron';

cron.schedule('0 9 * * *', async () => {
  await sendDailyDigest();            // your code
}, { timezone: 'Asia/Kolkata' });

That runs sendDailyDigest at 9:00 am India time, every day, for as long as the process stays up. The callback can be async. Errors inside it do not stop the schedule, so catch and log them yourself.

Reading a cron expression

A standard expression has five fields. node-cron also accepts an optional sixth field for seconds at the front.

Field Allowed Example
Minute 0-59 */15 = every 15 minutes
Hour 0-23 9-17 = 9 am to 5 pm
Day of month 1-31 1 = the 1st
Month 1-12 * = every month
Day of week 0-7 (0 and 7 are Sunday) 1-5 = Monday to Friday

Some patterns you will use often:

Schedule Expression
Every 5 minutes */5 * * * *
Every hour, on the hour 0 * * * *
Every day at 2 am 0 2 * * *
Weekdays at 9 am 0 9 * * 1-5
First of the month at midnight 0 0 1 * *

Test 1: two instances run every job twice

Diagram: with one instance each scheduled time runs once; with two instances each runs twice; with two instances and a Redis lock it runs once again, measured over 20 seconds as 20, 40 and 20 runs

Setup for all tests: node-cron 4.6.0 on Node.js 24, a schedule that fires every second so results show up quickly, and Redis 8.10.3 to count runs. Measured 7 November 2026 on a laptop.

This is a common problem. You scale a web app to two instances for reliability. Each instance loads the same code, so each one starts its own copy of the schedule.

Setup (20 seconds) Runs Seconds with more than one run
One instance 20 0
Two instances 40 20 of 20
Two instances with a Redis lock 20 0 (the other instance’s attempts were skipped by the lock)

With two instances, every scheduled job ran twice. For a cleanup that is harmless. For a daily digest it means every user gets two emails. For a billing job it means two charges.

The fix is to let only one instance do the work for each scheduled time. A short lock in Redis does it:

js
cron.schedule('0 9 * * *', async () => {
  const slot = new Date().toISOString().slice(0, 16);           // one key per minute
  const won = await redis.set(`lock:digest:${slot}`, '1', 'NX', 'EX', 300);
  if (!won) return;                                              // another instance has it
  await sendDailyDigest();
});

SET ... NX succeeds for only one caller, so exactly one instance runs the job. In our test the lock cut 40 runs back to 20.

Test 2: a slow job piles up

Next, a job that takes 2.5 seconds, scheduled every second. In real life, think of a report that usually takes 4 minutes, scheduled every 5, on a day it takes 7.

Setup (15 seconds) Runs started Most running at the same time
Default 15 3
noOverlap: true 5 1

By default, node-cron starts the next run even if the last one has not finished. Three copies of the job were running at once. If each one reads and writes the same rows, they get in each other’s way.

The noOverlap option skips a run while the previous one is still going:

js
cron.schedule('*/5 * * * *', buildReport, { noOverlap: true });

That is usually what you want. The cost is that a skipped run is skipped, not delayed.

Test 3: a restart skips runs

Finally, we stopped the only instance for 5 seconds, as a deploy or a crash would, and started it again.

Setup (20-second window) Runs
Instance down for about 5 seconds 14 (6 scheduled seconds missed)

node-cron keeps its schedule in memory. While the process is down, nothing runs, and nothing catches up when it comes back. A deploy at 1:59 am can silently skip your 2 am backup.

For a job that must run, record when it last succeeded, for example a row in your database. On start-up, check that time and run the job if it is overdue.

One good result: timing was accurate. Across the tests, runs fired a median of 7 to 12 ms after the scheduled second, and the 99th percentile stayed between 15 and 35 ms.

Timezones: the 2:30 pm surprise

node-cron uses the server’s timezone unless you pass one. Most cloud servers and containers run on UTC. So 0 9 * * * fires at 9:00 UTC, which is 2:30 pm in India.

Always set timezone: 'Asia/Kolkata' if you mean Indian time. India has no daylight saving, so the time never shifts during the year. Zones with daylight saving can skip or repeat an hour twice a year.

In-process cron or a separate cron job?

Running cron inside your web app is fine for small, safe jobs. It gets risky as soon as you run more than one instance or the job matters.

node-cron inside the app A separate cron job, one instance
Duplicate runs when the app scales Yes, unless you add a lock No, it does not scale with the app
Runs while the web app is down No Yes, it is a separate process
Extra cost None Usually a small instance or free tier
Best for Cache warm-ups, small cleanups Reports, emails, billing, backups

A separate cron job is its own process with one instance, so it does not care how many web instances you have. Overlap and missed runs (Tests 2 and 3) can still happen there, so keep jobs safe to run twice.

For queued work that should run as soon as possible, a job queue fits better; see our Redis job queue guide.

Run a Node.js cron job on Voroa

On Voroa you can create a Cron Job straight from your repository: give it a command such as node scripts/digest.js and a schedule. Voroa builds the repository into a small service that stays running and runs your command inside it on schedule. It is separate from your web services, so scaling the app never doubles a job.

Two things to plan for. Runs reuse the same running container rather than a fresh one each time, and a slow run can overlap the next. And if the service is stopped, scheduled runs are skipped, not caught up later. Keep jobs idempotent and record the last successful run, as in Test 3.

Schedules run in India Standard Time, so 0 9 * * * means 9:00 am IST. Setup is in scheduling a task on Voroa, and worker or cron job helps you pick the right type. More on long-running processes is on the background workers page.

FAQ

How do I run a cron job in Node.js? Install node-cron, call cron.schedule(expression, function), and keep the process running. Use the timezone option for local time.

Why does my Node.js cron job run twice? Usually because the app runs on two instances, or the process started twice. In our test, two instances ran every job twice. Use a lock or a separate cron job.

Does node-cron run missed jobs after a restart? No. It keeps the schedule in memory, so runs during downtime are skipped. Track the last successful run yourself if a job must not be missed.

How do I stop overlapping runs in node-cron? Pass { noOverlap: true }. A run is skipped while the previous one is still in progress.

What timezone does node-cron use? The server’s, usually UTC in the cloud. Pass timezone: 'Asia/Kolkata' for Indian time.

node-cron or a cron job service? node-cron for small in-app tasks. A separate cron job for jobs that must not run twice because the app scaled, or must survive app deploys. Keep them idempotent either way.

Tested 7 November 2026 with node-cron 4.6.0 and Node.js 24. Found something out of date? Write to [email protected].

Advertisement