DEV Community

Alex Chen
Alex Chen

Posted on

Cron Jobs in Node.js: The Practical Guide Nobody Gave Me

Cron Jobs in Node.js: The Practical Guide Nobody Gave Me

I spent 3 days learning things about cron in Node.js that should have been documented somewhere. Here is everything I wish I had known.

The Basics Everyone Shows You

const cron = require('node-cron');

// Run every 5 minutes
cron.schedule('*/5 * * * *', () => {
  console.log('Running every 5 minutes');
});

// Run daily at 9 AM
cron.schedule('0 9 * * *', () => {
  console.log('Good morning!');
});
Enter fullscreen mode Exit fullscreen mode

Simple enough. node-cron has 50k+ weekly downloads for a reason.

But here is what the tutorials do NOT tell you.

1. Your Process Will Crash (And Your Cron Jobs Die)

This is the #1 thing that bit me:

// app.js
const cron = require('node-cron');

cron.schedule('*/5 * * * *', async () => {
  // If THIS throws, the entire process dies
  const data = await fetchSomethingThatMightFail();
  processData(data); // Can also throw
});
Enter fullscreen mode Exit fullscreen mode

When an unhandled exception occurs inside your cron callback, it kills your whole Node.js process, including all other cron jobs.

Solution: Wrap Everything

cron.schedule('*/5 * * * *', async () => {
  try {
    const data = await await fetchSomethingThatMightFail();
    processData(data);
  } catch (err) {
    console.error('[cron] Task failed:', err.message);
    // Process keeps running! Other jobs unaffected!
  }
});
Enter fullscreen mode Exit fullscreen mode

Even better — make a wrapper:

function safeJob(name, fn) {
  return async () => {
    const start = Date.now();
    try {
      await fn();
      console.log(`[cron] ${name} OK (${Date.now()-start}ms)`);
    } catch (err) {
      console.error(`[cron] ${name} FAILED: ${err.message}`);
      // Optionally send alert
    }
  };
}

// Usage
cron.schedule('*/5 * * * *', safeJob('check-prs', async () => {
  const prs = await checkGitHubPRs();
  if (prs.length > 0) await notifyTeam(prs));
}));
Enter fullscreen mode Exit fullscreen mode

2. Timezones Will Bite You

// This runs at 9 AM... but whose 9 AM?
cron.schedule('0 9 * * *', () => { ... });
Enter fullscreen mode Exit fullscreen mode

It runs at 9 AM server local time. If your server is in UTC and you are in Hong Kong (UTC+8), that is 5 PM your time, not 9 AM.

Solution: Be Explicit

const cron = require('node-cron');

// Method 1: Specify timezone
cron.schedule('0 9 * * *', () => {
  // Runs at 9 AM Hong Kong time
}, { timezone: 'Asia/Hong_Kong' });

// Method 2: Use UTC and calculate offset
// 9 AM Hong Kong = 1 AM UTC
cron.schedule('0 1 * * *', () => {
  // Runs at 9 AM HKT (assuming server is UTC)
});

// Method 3: Use luxon for clarity
const { DateTime } = require('luxon');
const hktNow = DateTime.now().setZone('Asia/Hong_Kong');
console.log(hktNow.hour); // Current hour in HKT
Enter fullscreen mode Exit fullscreen mode

3. Overlapping Runs Are a Real Problem

What happens when a job takes longer than its interval?

// Runs every 5 minutes
cron.schedule('*/5 * * * *', async () => {
  // But this task takes 8 minutes!
  await longRunningTask(); 
});
Enter fullscreen mode Exit fullscreen mode

Result: Multiple instances running simultaneously. Race conditions. Database locks. Chaos.

Solution: File-Based Lock

const fs = require('fs');
const path = require('path');

function preventOverlap(jobName, fn) {
  const lockFile = path.join(__dirname, '.cron-locks', jobName + '.lock');

  return async () => {
    // Check if lock exists
    if (fs.existsSync(lockFile)) {
      const age = Date.now() - fs.statSync(lockFile).mtimeMs;
      if (age < 30 * 60 * 1000) { // Lock younger than 30 min
        console.log(`[cron] ${jobName}: Skipping (already running)`);
        return; // Skip this run
      }
      // Lock is stale (>30 min), remove it
      fs.unlinkSync(lockFile);
    }

    // Create lock
    fs.mkdirSync(path.dirname(lockFile), { recursive: true });
    fs.writeFileSync(lockFile, process.pid.toString());

    try {
      await fn();
    } finally {
      // Always remove lock
      if (fs.existsSync(lockFile)) fs.unlinkSync(lockFile);
    }
  };
}

// Usage
cron.schedule('*/5 * * * *', preventOverlap('long-task', async () => {
  await longRunningTask(); // Safe now!
}));
Enter fullscreen mode Exit fullscreen mode

4. Persistence: Surviving Restarts

Your VPS reboots. Power goes out. You deploy new code. All your in-memory cron state is gone.

What Needs Persisting

class CronState {
  constructor(stateDir) {
    this.stateDir = stateDir;
    fs.mkdirSync(stateDir, { recursive: true });
  }

  getLastRun(jobName) {
    const file = path.join(this.stateDir, jobName + '.json');
    try {
      return JSON.parse(fs.readFileSync(file, 'utf8')).lastRun;
    } catch {
      return null;
    }
  }

  setLastRun(jobName) {
    const file = path.join(this.stateDir, jobName + '.json');
    fs.writeFileSync(file, JSON.stringify({
      lastRun: Date.now(),
      pid: process.pid
    }));
  }

  shouldRun(jobName, intervalMs) {
    const lastRun = this.getLastRun(jobName);
    if (!lastRun) return true;
    return (Date.now() - lastRun) >= intervalMs;
  }
}

const state = new CronState('./cron-state');

cron.schedule('*/5 * * * *', () => {
  if (!state.shouldRun('check-alerts', 5 * 60 * 1000)) return;

  checkAlerts();
  state.setLastRun('check-alerts');
});
Enter fullscreen mode Exit fullscreen mode

5. Logging: Make It Searchable

Do not just console.log. You will regret it when you need to debug something from 2 weeks ago:

const winston = require('winston');

const logger = winston.createLogger({
  level: 'info',
  format: winston.format.combine(
    winston.format.timestamp(),
    winston.format.json()
  ),
  transports: [
    new winston.transports.File({ filename: 'cron-error.log', level: 'error' }),
    new winston.transports.File({ filename: 'cron-combined.log' }),
    new winston.transports.Console() // Also see in terminal
  ]
});

// In your cron job
logger.info('PR check completed', { count: 25, duration_ms: 1234 });
logger.error('Failed to fetch data', { error: err.message, url: targetUrl });

// Search later:
// grep -i "error" cron-combined.log | tail -20
// cat cron-combined.log | jq 'select(.level=="error")'
Enter fullscreen mode Exit fullscreen mode

6. The Production Setup That Actually Works

Here is my complete setup for a 24/7 Node.js process with cron:

// index.js — entry point
const cron = require('node-cron');
const logger = require('./logger');
const state = new (require('./cron-state'))('./cron-state');

// --- Utility wrappers ---
function job(name, interval, fn) {
  return cron.schedule(interval, async () => {
    if (!state.shouldRun(name, parseInterval(interval))) return;

    const start = Date.now();
    try {
      await fn();
      logger.info(`${name} completed`, { duration_ms: Date.now() - start });
    } catch (err) {
      logger.error(`${name} failed`, { error: err.message, stack: err.stack });
    } finally {
      state.setLastRun(name);
    }
  });
}

// --- Scheduled tasks ---
job('health-check', '*/5 * * * *', async () => {
  await checkAllEndpoints();
});

job('pr-monitor', '*/30 * * * *', async () => {
  const changes = await monitorGitHubPRs();
  if (changes.length > 0) await sendAlert(changes);
});

job('daily-report', '0 9 * * *', async () => {
  const report = await generateDailyReport();
  await emailReport(report);
}, { timezone: 'Asia/Hong_Kong' }); // 9 AM HKT

job('cleanup', '0 3 * * 0', async () => {
  // Weekly cleanup on Sundays at 3 AM
  await cleanOldLogs();
  await pruneTempFiles();
});

// --- Graceful shutdown ---
process.on('SIGTERM', () => {
  logger.info('Shutting down gracefully...');
  cron.getTasks().forEach(task => task.stop());
  setTimeout(() => process.exit(0), 5000);
});

logger.info('Cron scheduler started', { pid: process.pid });
Enter fullscreen mode Exit fullscreen mode

With systemd service file:

[Unit]
Description=Cron Worker
After=network.target

[Service]
Type=simple
ExecStart=/usr/bin/node /opt/app/index.js
WorkingDirectory=/opt/app
Restart=always
RestartSec=10
Environment=NODE_ENV=production

[Install]
WantedBy=multi-user.target
Enter fullscreen mode Exit fullscreen mode

Quick Reference: Cron Expression Cheat Sheet

Expression Meaning
* * * * * Every minute
*/5 * * * * Every 5 minutes
0 * * * * Every hour at :00
0 */2 * * * Every 2 hours
0 9 * * * Daily at 9 AM (server time)
0 9 * * 1 Every Monday at 9 AM
0 9 1 * * 1st of every month at 9 AM
*/15 9-17 * * 1-5 Every 15 min, 9AM-5PM, Mon-Fri

What About Alternatives?

Tool Best For Downside
node-cron In-process scheduling Dies with process
node-cron + systemd 24/7 reliability What I use
Bull/BullMQ Queue-based jobs Needs Redis
agenda.js MongoDB-backed persistence Heavy dependency
Bree Advanced features Smaller community

For most side projects: node-cron + systemd. Simple, reliable, zero extra infrastructure.


What cron pitfalls have you encountered? Drop a comment below.

More from Alex Chen: My Blog | Docker vs systemd

Top comments (0)