Cron Job in Node.js: node-cron Tested for Duplicate Runs
Sagar Tayde · · 7 min read
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
- Set up node-cron in five lines
- Reading a cron expression
- Test 1: two instances run every job twice
- Test 2: a slow job piles up
- Test 3: a restart skips runs
- Timezones: the 2:30 pm surprise
- In-process cron or a separate cron job?
- FAQ
Set up node-cron in five lines
npm install node-cron
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

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:
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:
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 support@getvoroa.com.