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
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();
});
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);
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
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'
}
}]
};
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
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
Enable it:
sudo systemctl enable my-timer.timer
sudo systemctl start my-timer.timer
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)
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();
});
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
});
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);
}
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);
}
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
Performance Tips
- Don't block the event loop. Heavy cron jobs should use worker threads or child_process.
- Set reasonable intervals. Every 5 minutes is fine for monitoring. Every second is not.
- Use connection pooling. If your job hits a database, reuse connections.
- Log intelligently. Log errors always. Log success occasionally (every Nth run).
- 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)
│ │ │ │ │
* * * * *
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)