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 hours debugging why my cron job only ran once. Here's everything I wish I'd known before starting.

Why This Matters

Every serious application needs scheduled tasks:

  • Send daily digest emails at 9 AM
  • Clean up temp files every Sunday
  • Generate reports on the 1st of each month
  • Check API health every 5 minutes

I've tried every approach. Here's what works, what doesn't, and what I use in production.

Option 1: node-cron (Best for Most Cases)

npm install node-cron
Enter fullscreen mode Exit fullscreen mode

The gold standard for Node.js scheduling:

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

// Run every day at 9:00 AM
cron.schedule('0 9 * * *', () => {
  console.log('Sending daily digest...');
  sendDailyDigest();
});

// Run every 5 minutes
cron.schedule('*/5 * * * *', async () => {
  const status = await checkAPIHealth();
  if (!status.ok) alertAdmin(status);
});

// Run on Mondays at 8:00 AM
cron.schedule('0 8 * * 1', () => {
  generateWeeklyReport();
});
Enter fullscreen mode Exit fullscreen mode

Why it wins:

  • Zero dependencies (pure JS implementation)
  • Full cron syntax support
  • 2M+ weekly downloads, battle-tested
  • Works inside existing Node.js process

Gotcha: When your process crashes, your cron stops. See the "Making It Reliable" section below.

Option 2: setInterval (For Simple Cases)

Don't need a library? Use built-in Node.js:

// Every hour
setInterval(() => {
  cleanupTempFiles();
}, 3600000);

// Every 30 seconds (for polling)
const poll = setInterval(async () => {
  const result = await checkSomething();
  if (result.done) clearInterval(poll);
}, 30000);
Enter fullscreen mode Exit fullscreen mode

When to use this:

  • Simple intervals (no complex schedules)
  • Quick scripts where adding a dependency feels wrong
  • One-off tasks in a larger app

When NOT to use:

  • You need "at 9 AM on weekdays" type scheduling
  • You need timezone-aware execution
  • Long-running processes where drift matters

Option 3: PM2 + Cron (For Production)

If you're already using PM2 to manage your Node.js process:

# Install PM2 globally
npm install pm2 -g

# Start your app with PM2
pm2 start server.js --name "my-app"

# Set up a cron job via PM2
pm2 save
pm2 startup  # generates systemd service
Enter fullscreen mode Exit fullscreen mode

PM2 will auto-restart your process if it crashes, which means your node-cron jobs keep running too.

My production setup:

// ecosystem.config.js
module.exports = {
  apps: [{
    name: 'automation-bot',
    script: 'server.js',
    instances: 1,
    autorestart: true,
    max_memory_restart: '500M',
    env: {
      NODE_ENV: 'production'
    }
  }]
};
Enter fullscreen mode Exit fullscreen mode

Run with: pm2 start ecosystem.config.js

Option 4: Systemd Timers (For System-Level Tasks)

Need something that runs even when Node.js isn't running?

Create /etc/systemd/system/my-timer.timer:

[Unit]
Description=Run my task daily

[Timer]
OnCalendar=*-*-* 09:00:00
Persistent=true

[Install]
WantedBy=timers.target
Enter fullscreen mode Exit fullscreen mode

And /etc/systemd/system/my-timer.service:

[Unit]
Description=My task runner

[Service]
Type=oneshot
ExecStart=/usr/bin/node /path/to/script.js
WorkingDirectory=/path/to/project
Enter fullscreen mode Exit fullscreen mode

Enable it:

sudo systemctl enable my-timer.timer
sudo systemctl start my-timer.timer
Enter fullscreen mode Exit fullscreen mode

Advantages:

  • Survives reboots automatically
  • Runs as any user you specify
  • Independent of any application process
  • Built-in logging via journalctl

Making Cron Jobs Reliable

This is the part most tutorials skip. Here's how I learned (the hard way) to make scheduled tasks actually reliable.

Problem 1: Process Crashes → Jobs Stop

Solution: Use PM2 or systemd with restart policies.

# PM2 approach
pm2 start server.js --name bot
pm2 save

# Systemd approach (see Option 4 above)
Enter fullscreen mode Exit fullscreen mode

Problem 2: Duplicate Runs After Crash

Your process crashes at 8:59:59. Restarts at 9:00:01. The 9:00 AM job fires twice.

Solution: Persist state to disk.

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

function shouldRun(jobName, intervalMs) {
  const stateDir = './cron-state';
  const stateFile = path.join(stateDir, `${jobName}.json`);

  // Ensure state dir exists
  if (!fs.existsSync(stateDir)) fs.mkdirSync(stateDir, { recursive: true });

  const now = Date.now();

  try {
    const { lastRun } = JSON.parse(fs.readFileSync(stateFile));
    if (now - lastRun < intervalMs) return false;
  } catch (err) {
    // First run — file doesn't exist yet
  }

  fs.writeFileSync(stateFile, JSON.stringify({ lastRun: now }));
  return true;
}

// Usage
cron.schedule('0 9 * * *', () => {
  if (!shouldRun('daily-digest', 24 * 60 * 60 * 1000)) return;
  sendDailyDigest();
});
Enter fullscreen mode Exit fullscreen mode

Problem 3: Timezone Confusion

node-cron uses the system timezone by default. If your server is UTC but you want 9 AM EST:

const TZ = 'America/New_York';

cron.schedule('0 9 * * *', () => {
  // This runs at 9 AM New York time
  sendDigest();
}, {
  timezone: TZ
});
Enter fullscreen mode Exit fullscreen mode

Problem 4: Silent Failures

Your job throws an error at 3 AM. Nobody notices for 3 days.

Solution: Wrap everything in error handling + logging.

cron.schedule('*/5 * * * *', async () => {
  const startTime = Date.now();
  try {
    await checkAllRepos();
    logJobSuccess('repo-check', Date.now() - startTime);
  } catch (err) {
    logJobError('repo-check', err);
    notifyAdmin(`Job failed: ${err.message}`);
  }
});

function logJobSuccess(name, duration) {
  const entry = `[${new Date().toISOString()}] OK ${name} (${duration}ms)\n`;
  fs.appendFileSync('./logs/cron.log', entry);
}

function logJobError(name, err) {
  const entry = `[${new Date().toISOString()}] FAIL ${name}: ${err.stack}\n`;
  fs.appendFileSync('./logs/cron.log', entry);
}
Enter fullscreen mode Exit fullscreen mode

Real-World Example: My Monitoring Bot

Here's the actual setup I run on my $5 VPS:

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

// Ensure log directory exists
if (!fs.existsSync('./logs')) fs.mkdirSync('./logs');

// === URGENT: Every 5 minutes ===
cron.schedule('*/5 * * * *', async () => {
  await wrapJob('urgent-check', async () => {
    const alerts = await checkUrgentAlerts();
    if (alerts.length > 0) await notifyTeam(alerts);
  });
});

// === ROUTINE: Every 30 minutes ===
cron.schedule('*/30 * * * *', async () => {
  await wrapJob('pr-monitor', async () => {
    await monitorGitHubPRs();
  });
  await wrapJob('task-scan', async () => {
    await scanForNewTasks();
  });
});

// === HOURLY: Every hour ===
cron.schedule('0 * * * *', async () => {
  await wrapJob('hourly-digest', async () => {
    await sendHourlyDigest();
  });
});

// === DAILY: 9 AM Hong Kong time ===
cron.schedule('0 9 * * *', async () => {
  await wrapJob('daily-report', async () => {
    await generateDailyReport();
  });
}, { timezone: 'Asia/Hong_Kong' });

// Helper wrapper
async function wrapJob(name, fn) {
  const start = Date.now();
  try {
    await fn();
    log(name, true, null, Date.now() - start);
  } catch (err) {
    log(name, false, err, Date.now() - start);
  }
}

function log(name, ok, err, ms) {
  const line = `${new Date().toISOString()} | ${ok ? 'OK' : 'FAIL'} | ${name.padEnd(20)} | ${ms}ms | ${err ? err.message : '-'}\n`;
  fs.appendFileSync('./logs/cron.log', line);
}
Enter fullscreen mode Exit fullscreen mode

Sample log output:

2026-05-15T17:05:01Z | OK   | urgent-check        | 2341ms | -
2026-05-15T17:10:02Z | OK   | urgent-check        | 1987ms | -
2026-05-15T17:30:01Z | OK   | pr-monitor          | 5621ms | -
2026-05-15T17:30:04Z | OK   | task-scan           | 3422ms | -
2026-05-15T18:00:01Z | OK   | hourly-digest       | 1234ms | -
2026-05-16T00:59:59Z | FAIL | daily-report        | 892ms  | API rate limit exceeded
Enter fullscreen mode Exit fullscreen mode

Performance Tips

  1. Don't block the event loop. Heavy cron jobs should use worker threads or child_process.
  2. Set reasonable intervals. Every 5 minutes is fine for monitoring. Every second is not.
  3. Use connection pooling. If your job hits a database, reuse connections.
  4. Log intelligently. Log errors always. Log success occasionally (every Nth run).
  5. Monitor your monitor. Have an external health check confirm your cron process is alive.

Quick Reference: Cron Syntax

┌───────────── minute (0-59)
│ ┌───────────── hour (0-23)
│ │ ┌───────────── day of month (1-31)
│ │ │ ┌───────────── month (1-12)
│ │ │ │ ┌───────────── day of week (0-6, Sun=0)
│ │ │ │ │
* * * * *
Enter fullscreen mode Exit fullscreen mode

Common patterns:
| Pattern | Meaning |
|---------|---------|
| * * * * * | Every minute |
| */5 * * * * | Every 5 minutes |
| 0 */2 * * * | Every 2 hours |
| 0 9 * * 1-5 | 9 AM on weekdays |
| 0 9 1 * * | 9 AM on 1st of month |
| */15 9-17 * * * | Every 15 min between 9AM-5PM |

Use crontab.guru to test expressions visually.

What I Use Today

After 6 months of running scheduled tasks in production:

Task Method Interval
API health checks node-cron 5 min
PR monitoring node-cron 30 min
Report generation node-cron Hourly/Daily
Database backups systemd timer Daily 3 AM
Log rotation logrotate (Linux) Weekly

The stack: PM2 + node-cron for app-level jobs, systemd timers for system-level tasks.


What's your favorite way to handle scheduled tasks in Node.js? Drop a comment below.

Enjoyed this? Follow @armorbreak for more practical Node.js guides.

Top comments (0)