How to Build a Task Scheduler in Node.js (node-cron, Timezones, No Overlaps)

Muhaymin Bin Mehmood

Muhaymin Bin Mehmood

·
6 min read
Published Feb 3, 2025
Updated Oct 9, 2026
How to Build a Task Scheduler in JavaScript (Step-by-Step) Banner Image
11 min read
968 words
15 sections9 code blocks

A task scheduler runs code at set times: send a report at 9:00, clean up sessions every 5 minutes, email reminders on weekdays. In Node.js the usual tool is node-cron, which takes a cron expression and a function. The hard parts aren't the schedule itself but what happens around it: timezones, a job still running when the next one starts, failures, deploys and running on more than one server. This guide covers all of them.

Tested with: Node.js 20.20 and node-cron 4.6, October 2026. To observe behavior quickly we ran the jobs with every-second patterns; the results below come from those runs.

Setup

Bash
mkdir task-scheduler && cd task-scheduler
npm init -y
npm pkg set type=module
npm install node-cron

Cron Expressions in 1 Minute

Bash
┌──────────── second (optional, 0-59)
│ ┌────────── minute (0-59)
│ │ ┌──────── hour (0-23)
│ │ │ ┌────── day of month (1-31)
│ │ │ │ ┌──── month (1-12)
│ │ │ │ │ ┌── day of week (0-7, 0 and 7 = Sunday)
│ │ │ │ │ │
* * * * * *
ExpressionRuns
* * * * *Every minute
*/5 * * * *Every 5 minutes
0 * * * *At the start of every hour
0 9 * * *Every day at 09:00
30 18 * * 1-5Weekdays at 18:30
0 0 1 * *Midnight on the 1st of every month
*/10 * * * * *Every 10 seconds (6 fields)

Check expressions before you use them, especially ones users type in:

JavaScript
cron.validate("*/5 * * * *"); // true
cron.validate("61 * * * *");  // false
cron.validateDetailed("61 * * * *").errors;
// [{ field: "minute", value: "61", message: "61 is a invalid expression for minute" }]

Tip: crontab.guru explains any 5-field expression in plain English.

A Production-Ready Scheduler File

JavaScript
// npm install node-cron   (v4)
import cron from "node-cron";

// Every day at 09:00 Karachi time, never two runs at once
const dailyReport = cron.schedule(
  "0 9 * * *",
  async ({ date }) => {
    console.log("Sending daily report for", date.toISOString());
    await sendDailyReport();
  },
  { name: "daily-report", timezone: "Asia/Karachi", noOverlap: true }
);

// Every 5 minutes
cron.schedule("*/5 * * * *", () => cleanUpExpiredSessions(), { name: "session-cleanup" });

// Log failures (async errors are caught by node-cron, not thrown)
dailyReport.on("execution:failed", ({ execution }) => {
  console.error("daily-report failed:", execution?.error?.message);
});

console.log("Next report runs:", dailyReport.getNextRuns(2).map((d) => d.toISOString()));

// Let running jobs finish before the process exits (deploys, Ctrl+C)
for (const signal of ["SIGINT", "SIGTERM"]) {
  process.once(signal, async () => {
    await cron.shutdown();
    process.exit(0);
  });
}

async function sendDailyReport() { /* query the DB, send an email… */ }
function cleanUpExpiredSessions() { /* delete old rows… */ }

What each option did in our tests:

  • timezone: without it, times use the server's timezone, which is usually UTC on cloud servers. With timezone: "Asia/Karachi", getNextRuns() returned 04:00 UTC for "9:00"; with America/New_York, 13:00 UTC. No moment-timezone needed.
  • noOverlap: we gave a job 2.5 seconds of work and ran it every second. Without noOverlap, up to 3 copies ran at the same time. With it, never more than one: the extra ticks were skipped and node-cron logged a warning.
  • Errors: a job that threw "SMTP timeout" didn't crash the process; node-cron logged it, our execution:failed listener ran, and the next scheduled run went ahead normally.
  • Shutdown: we called cron.shutdown() while the report job was busy. It waited for that run to finish (about 1.1 seconds) before resolving, so a deploy or Ctrl+C doesn't cut a job in half.

Adding and Removing Jobs at Runtime

For schedules that come from users or a database, wrap node-cron in a small class:

JavaScript
import cron from "node-cron";

// Add, remove and list jobs at runtime (e.g. user-defined reminders)
export class TaskScheduler {
  #tasks = new Map();

  async add(name, expression, fn, options = {}) {
    if (!cron.validate(expression)) throw new Error(`Invalid cron expression: ${expression}`);
    await this.remove(name); // replacing a job with the same name
    const task = cron.schedule(expression, fn, { name, noOverlap: true, ...options });
    this.#tasks.set(name, task);
    return task;
  }

  async remove(name) {
    const task = this.#tasks.get(name);
    if (!task) return false;
    await task.destroy();
    this.#tasks.delete(name);
    return true;
  }

  list() {
    return [...this.#tasks].map(([name, task]) => ({
      name,
      pattern: task.getPattern(),
      status: task.getStatus(),
      nextRun: task.getNextRun()?.toISOString() ?? null,
    }));
  }
}
JavaScript
const scheduler = new TaskScheduler();

await scheduler.add("reminder:42", "30 18 * * 1-5", () => remindUser(42), { timezone: "Asia/Karachi" });
scheduler.list();
// [{ name: "reminder:42", pattern: "30 18 * * 1-5", status: "idle", nextRun: "…" }]
await scheduler.remove("reminder:42");
  • Adding a job with an existing name replaces it: in our test only the new version ran.
  • remove() calls destroy(), which stops the timer for good; stop() only pauses it.
  • An invalid expression throws before anything is scheduled.
  • add() is async on purpose: removing the old task is async, and not awaiting it can delete the new task's entry from the map.

Without a Library: setTimeout and Its 24.8-Day Limit

For one-off jobs ("run this at 3 pm tomorrow") you might reach for setTimeout. Be careful with long delays: Node.js (and browsers) store the delay as a 32-bit integer. In our test, a 30-day setTimeout ran after 16 ms with a TimeoutOverflowWarning. Split long waits into chunks:

JavaScript
// setTimeout can't wait longer than 2,147,483,647 ms (~24.8 days):
// longer delays fire almost immediately. Split long waits into chunks.
const MAX_DELAY = 2 ** 31 - 1;

export function setLongTimeout(fn, ms) {
  const handle = { id: null };
  const tick = (remaining) => {
    handle.id = setTimeout(
      () => (remaining > MAX_DELAY ? tick(remaining - MAX_DELAY) : fn()),
      Math.min(remaining, MAX_DELAY)
    );
  };
  tick(ms);
  return handle;
}

export function clearLongTimeout(handle) {
  clearTimeout(handle.id);
}

// Run once at a specific date
export function runAt(date, fn) {
  return setLongTimeout(fn, Math.max(0, date.getTime() - Date.now()));
}

Tested: the 30-day timeout had not fired after 300 ms, runAt 250 ms in the future fired after about 260 ms, and a date in the past ran right away. For recurring schedules, stick with cron expressions; for clearing many timers, see our clearing timeouts guide. Reference: Node.js timers.

Running on More Than One Server

If your app runs on two servers, or in PM2 cluster mode with four workers, every copy schedules the same jobs, and the daily email goes out several times. node-cron 4 can ask a run coordinator before each run:

JavaScript
import cron from "node-cron";

// With several servers (or PM2 cluster workers) every copy of your app
// schedules the same job. A run coordinator lets only one of them run it.
// This in-memory version shows the shape; in production back it with
// something all servers share, e.g. Redis SET key value NX PX ttl.
const leases = new Map();
const coordinator = {
  shouldRun(key, ttlMs) {
    const now = Date.now();
    if ((leases.get(key) ?? 0) > now) return false; // someone else has it
    leases.set(key, now + ttlMs);
    return true;
  },
};

cron.schedule("0 * * * *", () => generateHourlyStats(), {
  name: "hourly-stats", // the lock key
  distributed: true,
  runCoordinator: coordinator,
});

function generateHourlyStats() { /* … */ }

We scheduled the same named job twice in one process with a shared coordinator: across three ticks it ran three times in total, once per tick, never twice. The in-memory lease only works inside one process; for real servers, store the lease somewhere they all share (a Redis key with an expiry, or a database row), or run the scheduler in exactly one dedicated process.

When node-cron Isn't Enough

NeedUse
Jobs must survive restarts / never be missedA persistent queue: BullMQ (Redis) or Agenda (MongoDB)
Retries with backoff, job history, dashboardsBullMQ or a hosted queue
Serverless app (Vercel, Netlify, Lambda)The platform's cron calling an API route
Simple server, jobs as separate scriptsSystem crontab or a systemd timer
Repo automation (reports, backups)GitHub Actions on: schedule

node-cron keeps schedules in memory: if the server is down at 9:00, that run is simply missed. Serverless functions don't stay running, so in-process cron doesn't work there at all; use the platform scheduler instead. On Vercel, a vercel.json like this calls your API route on schedule:

JSON
{
  "crons": [{ "path": "/api/cron/daily-report", "schedule": "0 4 * * *" }]
}

Vercel cron schedules run in UTC, so 04:00 UTC is 09:00 in Pakistan. Protect the route (for example by checking the secret header your platform sends) so strangers can't trigger it.

Best Practices

  • Make jobs idempotent: running twice should be harmless (check "already sent today?" before sending).
  • Set a timezone explicitly for anything users think of in local time.
  • Use noOverlap for jobs that can take longer than their interval.
  • Log start, end and failures with the job name; listen to execution:failed and alert on it.
  • Keep heavy work out of the web process: a big nightly export in your API server slows requests and can run out of memory (see fixing heap out of memory).
  • Shut down gracefully with cron.shutdown() on SIGTERM. More on robust error handling in our Node.js error handling guide.

Full options list: node-cron on GitHub.

FAQs

How do I schedule a task in Node.js?

Install node-cron and call cron.schedule("0 9 * * *", fn, { timezone }). For a one-time task at a specific date, use a timer (chunked if it's more than 24.8 days away) or a persistent queue.

What's the best Node.js scheduler library?

node-cron for simple in-process schedules; BullMQ or Agenda when jobs must persist, retry or run across several servers.

How do I run a cron job in a specific timezone?

Pass timezone: "Area/City" (an IANA name like "Asia/Karachi") in the options. Check the result with task.getNextRuns().

How do I stop a node-cron job?

task.stop() pauses it (start() resumes), task.destroy() removes it, and cron.shutdown() stops everything after running jobs finish.

Why does my job run twice?

Usually because two processes are running it: several servers, PM2 cluster mode, or a dev server that restarted without stopping the old one. Use a run coordinator or run the scheduler in a single process.

Can I schedule tasks in the browser?

Only while the page is open, with setTimeout/setInterval, and browsers slow timers down in background tabs. Anything that must happen on time belongs on the server.

No comments yet. Be the first to comment!

Leave a Comment

2000 characters remaining

Related Articles

View all
Muhaymin Bin Mehmood

About Muhaymin Bin Mehmood

Front-end Developer skilled in the MERN stack, experienced in web and mobile development. Proficient in React.js, Node.js, and Express.js, with a focus on client interactions, sales support, and high-performance applications.