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!');
});
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
});
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!
}
});
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));
}));
2. Timezones Will Bite You
// This runs at 9 AM... but whose 9 AM?
cron.schedule('0 9 * * *', () => { ... });
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
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();
});
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!
}));
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');
});
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")'
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 });
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
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)