Skip to content
NewHost
Menu

Cron Jobs in Node.js - Scheduled Tasks That Run Reliably in SA

Cron jobs in Node.js explained - cron syntax, node-cron vs platform schedulers, time zones, avoiding overlaps and duplicate runs, and securing job endpoints.

By NewHost team · · 6 min read

There are two ways to run cron jobs for a Node.js app: schedule them inside your app with a library such as node-cron, or let your hosting platform's scheduler trigger a script or endpoint on a cron schedule. In-process scheduling is quick to set up but runs once per instance and stops when the app restarts; a platform scheduler runs independently of your app and is usually the more reliable choice in production. This guide covers cron syntax, both approaches, time zones, and how to make jobs safe to run twice.

Cron syntax in 60 seconds

A standard cron expression has five fields:

# ┌──────── minute (0-59)
# │ ┌────── hour (0-23)
# │ │ ┌──── day of month (1-31)
# │ │ │ ┌── month (1-12)
# │ │ │ │ ┌ day of week (0-6, Sunday = 0)
# │ │ │ │ │
  0 2 * * *
Expression Meaning
*/5 * * * * Every 5 minutes
0 * * * * At the start of every hour
0 2 * * * Every day at 02:00
30 7 * * 1-5 07:30 on weekdays (Monday to Friday)
0 0 1 * * Midnight on the first day of every month
15 3 * * 0 03:15 every Sunday

Operators: * means every value, , separates a list (1,15), - is a range (1-5) and / is a step (*/15). Some libraries add a sixth field for seconds at the start; check before copying expressions between tools.

When both day-of-month and day-of-week are restricted (for example 0 9 1 * 1), classic cron runs the job when either matches - on the 1st and on every Monday - which surprises many people.

Option 1 - Scheduling inside your app with node-cron

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

cron.schedule(
  '0 2 * * *',
  async () => {
    try {
      await purgeExpiredSessions();
      console.log('Session cleanup done');
    } catch (err) {
      console.error('Session cleanup failed', err);
    }
  },
  { timezone: 'Africa/Johannesburg' }
);

async function purgeExpiredSessions() {
  // e.g. DELETE FROM sessions WHERE expires_at < NOW()
}

This is convenient, but understand the trade-offs:

  • It runs in every instance. Scale to two instances and the job runs twice.
  • Missed runs are lost. If the app is restarting or deploying at 02:00, the job doesn't run.
  • It competes with requests. A heavy job can slow down your API while it runs.

In-process scheduling is fine for lightweight, frequent housekeeping in a single-instance app. For anything important - invoices, reports, data retention - prefer an external scheduler.

Option 2 - Using your platform's scheduler

A platform scheduler runs your job on a cron schedule independently of your web process. Structure the job as a standalone script that does its work and exits:

// scripts/send-daily-report.js
import { sendDailyReport } from '../src/reports.js';

try {
  await sendDailyReport();
  console.log('Daily report sent');
  process.exit(0);
} catch (err) {
  console.error('Daily report failed', err);
  process.exit(1);
}

A non-zero exit code signals failure, so the scheduler (or your monitoring) can tell a failed run from a successful one. Run it locally with node scripts/send-daily-report.js to test.

NewHost includes cron-style scheduled tasks with your app hosting, running as often as every 5 minutes. If you need anything more frequent than that, it is usually a sign you want a queue or a long-running worker rather than cron.

Triggering an endpoint securely

Some schedulers call a URL instead of running a command. If you expose a job endpoint, protect it with a secret so nobody else can trigger it:

import crypto from 'node:crypto';
import express from 'express';
import { sendDailyReport } from './reports.js';

const app = express();

function isAuthorised(header) {
  const expected = Buffer.from(`Bearer ${process.env.CRON_SECRET}`);
  const received = Buffer.from(header ?? '');
  return received.length === expected.length && crypto.timingSafeEqual(received, expected);
}

app.post('/internal/jobs/daily-report', async (req, res) => {
  if (!isAuthorised(req.get('authorization'))) return res.sendStatus(401);
  try {
    await sendDailyReport();
    res.json({ ok: true });
  } catch (err) {
    console.error(err);
    res.status(500).json({ ok: false });
  }
});

app.listen(process.env.PORT ?? 3000);

crypto.timingSafeEqual throws if the buffers differ in length, which is why the length check comes first. Generate a long random secret (for example with openssl rand -hex 32) and keep it in an environment variable.

Time zones - SAST and UTC

South Africa uses SAST, which is UTC+2 all year, with no daylight saving time. Many servers and schedulers run in UTC, so 0 2 * * * on a UTC scheduler runs at 04:00 in Johannesburg.

  • Check which time zone your scheduler uses.
  • In code, set the zone explicitly, as with timezone: 'Africa/Johannesburg' in node-cron.
  • Store timestamps in the database in UTC and convert for display.

If your app also serves users in countries with daylight saving, remember that "02:00 local" can occur twice, or not at all, on changeover days. Avoid scheduling critical jobs between 01:00 and 03:00 in those zones.

Make jobs safe to run twice

Schedulers can fire twice, a retry can overlap a slow run, or someone can trigger a job by hand. Design every job to be idempotent, so a second run does no harm:

  • Use status flags. Select invoices WHERE status = 'pending' and mark each one sent in the same transaction that records the send.
  • Use a lock for jobs that must never overlap. A simple option on MySQL is a named lock:
// pool is a mysql2/promise pool
const conn = await pool.getConnection();
try {
  const [[{ locked }]] = await conn.query("SELECT GET_LOCK('daily-report', 0) AS locked");
  if (!locked) {
    console.log('Another run is in progress, skipping');
  } else {
    try {
      await sendDailyReport();
    } finally {
      await conn.query("SELECT RELEASE_LOCK('daily-report')");
    }
  }
} finally {
  conn.release();
}

GET_LOCK is tied to the database connection, which is why the code takes one connection from the pool and uses it for both acquiring and releasing the lock. If the process crashes, MySQL releases the lock when the connection closes.

  • Process in batches with a limit, so a backlog doesn't create one enormous run.

Monitor your jobs

A cron job that fails silently is worse than none. At a minimum:

  • log start, finish, duration and item counts
  • exit non-zero or return an error status on failure
  • alert when a job hasn't run - a "heartbeat" check that expects a ping after each successful run catches both failures and jobs that never started

Common uses

  • Data retention clean-ups (useful for POPIA - see POPIA for developers)
  • Nightly reports and email digests
  • Syncing data from a third-party API
  • Renewing or refreshing cached data
  • Sending reminders before bookings or renewals
  • Checking and tidying a managed MySQL database, such as archiving old rows

Frequently asked questions

What is the difference between cron and setInterval?

setInterval runs every N milliseconds from when the process started and is lost on restart. Cron runs at specific clock times, like 02:00 daily, and a platform scheduler keeps running even when your app restarts.

Can I run a cron job every minute?

Cron syntax supports it (* * * * *), but many hosting schedulers set a minimum interval. On NewHost the minimum is every 5 minutes. For near-real-time work, use a queue or a worker process instead.

How do I test a cron expression?

Write the expression, then list the next few run times using your cron library or a trusted checker before deploying. Also run the job script by hand to confirm it works outside the schedule.

Why did my job run at the wrong time?

Usually a time zone mismatch: the scheduler runs in UTC while you expected SAST (UTC+2). Set the time zone explicitly, or convert your expression to UTC.

For more ways to automate your app - webhooks, the REST API and scheduled tasks - see automation on NewHost, and read webhooks explained for event-driven jobs.

Related guides

Ready to launch on NewHost?

Choose a plan and go live today, or tell us what you need and we'll recommend the right setup.