β° Scheduled Tasks with Cron
Some background work runs on demand β but a lot of it runs on a clock. Purge expired sessions at 3 AM, email a weekly digest every Monday, generate invoices on the first of the month. This lesson teaches you to read and write cron expressions, run recurring jobs in-process with node-cron, and schedule durable repeatable jobs with BullMQ.
Week 10 · Day 5 (Friday: Background Jobs) · Lecture 3
π― Learning Objectives
By the end of this lesson, you will be able to:
- Decode any five-field cron expression and write your own for common schedules
- Schedule recurring work in Node with node-cron, including a time zone
- Create durable, crash-surviving repeatable jobs with BullMQ
- Choose between in-process scheduling and queue-backed scheduling for a given task
- Make scheduled tasks safe with idempotency, locking, and error handling
Estimated Time: 70 minutes
Project: Schedule a nightly cleanup and a Monday-morning digest, then move them onto BullMQ.
In This Lesson
Why Schedule Work?
Think of the automatic systems around your home: the thermostat that lowers the heat at night, the coffee maker that starts at 6 AM, the phone that backs itself up while you sleep. None of them wait for you to press a button. A web application has the same kind of recurring housekeeping, and a scheduler is what runs it on time, every time, without a human.
| Category | Example scheduled tasks |
|---|---|
| Maintenance | Delete expired sessions, rotate logs, back up the database |
| Data processing | Aggregate yesterday's analytics, rebuild search indexes |
| Engagement | Daily digests, weekly newsletters, "we miss you" emails |
| Business | Monthly invoices, recurring billing, low-stock alerts |
Scheduled tasks are just background jobs with a trigger that is time rather than a user action. Everything you learned about workers, retries, and idempotency still applies β we're only adding "run this on a schedule" to the front of it.
Reading Cron Expressions
Cron is a time-based scheduler born on Unix (the name comes from chronos, Greek for time). Its expressions are a compact language for "when should this run," and the same syntax is used by node-cron, BullMQ, GitHub Actions, and cloud schedulers. Learn it once, use it everywhere.
A standard cron expression has five fields separated by spaces:
ββββββββββββββ minute (0 - 59)
β ββββββββββββ hour (0 - 23)
β β ββββββββββ day of month (1 - 31)
β β β ββββββββ month (1 - 12)
β β β β ββββββ day of week (0 - 6, Sunday = 0)
β β β β β
* * * * *
* means "every value."The special characters
*β every value (a wildcard).* * * * *means "every minute.",β a list.0 9,17 * * *is 9 AM and 5 PM.-β a range.0 9 * * 1-5is 9 AM Monday through Friday./β a step.*/15 * * * *is every 15 minutes.
Expressions you'll actually use
| Expression | Meaning |
|---|---|
* * * * * | Every minute |
*/5 * * * * | Every 5 minutes |
0 * * * * | Every hour, on the hour |
0 0 * * * | Every day at midnight |
0 3 * * * | Every day at 3:00 AM |
0 9 * * 1-5 | 9:00 AM on weekdays |
0 10 * * 1 | 10:00 AM every Monday |
0 0 1 * * | Midnight on the first of every month |
π‘ A trick for building expressions
Fill the fields from the smallest unit up, and use 0 for anything you're not stepping. Want "every day at 3:30 AM"? Minute = 30, hour = 3, the rest * β 30 3 * * *. When in doubt, an online cron explainer will translate an expression to plain English while you're learning.
Scheduling with node-cron
node-cron is a tiny, zero-dependency scheduler that runs cron jobs inside your Node process. It's the fastest way to add a recurring task and perfect for lightweight, single-instance jobs.
// npm install node-cron
import cron from 'node-cron';
// Every day at 3:00 AM: clean up expired sessions.
cron.schedule('0 3 * * *', async () => {
console.log('Running nightly session cleanupβ¦');
await Session.deleteExpired();
});
// Every 15 minutes: check inventory and alert on low stock.
cron.schedule('*/15 * * * *', async () => {
await checkLowStock();
});
node-cron validates expressions, so a typo throws immediately rather than silently never running. You can also pin a task to a specific time zone β essential when "midnight" needs to mean midnight for your users, not for the server.
cron.schedule('0 0 * * *', () => {
console.log('Midnight in New York, regardless of server location');
}, {
timezone: 'America/New_York'
});
Separate the task from the schedule
Don't bury business logic inside the cron callback β you can't unit-test a schedule. Keep the what (the task) apart from the when (the schedule).
// tasks.js β the "what", pure and testable
export async function cleanupSessions() {
const removed = await Session.deleteExpired();
console.log(`Removed ${removed} expired sessions`);
}
// scheduler.js β the "when"
import cron from 'node-cron';
import { cleanupSessions } from './tasks.js';
cron.schedule('0 3 * * *', cleanupSessions);
β οΈ In-process cron has real limits
node-cron lives in your process, so if that process crashes, the schedule stops and a missed run is gone forever β there's no memory of it. Worse, if you scale to three web servers, all three run the job, and your users get three copies of the newsletter. In-process cron is great for a single instance; for anything replicated or mission-critical, reach for a queue.
BullMQ Repeatable Jobs
BullMQ can schedule too β and because the schedule lives in Redis, it survives restarts and is shared across every worker. You define a repeatable job with a cron pattern; BullMQ enqueues a fresh job each time the pattern fires, and your existing worker processes it with all the retries and backoff you already built.
// producer side β register the repeatable job once at startup
import { Queue } from 'bullmq';
import { connection } from './queues/connection.js';
const reportQueue = new Queue('reports', { connection });
// Every day at 3 AM, enqueue a 'daily-summary' run.
await reportQueue.add(
'daily-summary',
{ kind: 'daily' },
{
repeat: { pattern: '0 3 * * *', tz: 'America/New_York' },
removeOnComplete: 100,
removeOnFail: 500
}
);
The worker is exactly the kind you built last lesson β it doesn't know or care that the job was scheduled rather than triggered by a user.
// worker.js β runs in its own process
import { Worker } from 'bullmq';
import { connection } from './queues/connection.js';
const worker = new Worker('reports', async (job) => {
console.log(`Generating ${job.data.kind} report at ${new Date().toISOString()}`);
await generateReport(job.data.kind);
return { generatedAt: Date.now() };
}, { connection });
One-off delayed jobs
Not everything repeats. To run something once at a future time β say, a reminder 24 hours after signup β use a plain delay instead of repeat.
await reminderQueue.add(
'day-1-reminder',
{ userId: 42 },
{ delay: 24 * 60 * 60 * 1000 } // fire once, 24 hours from now
);
Managing repeatable jobs
Adding the same repeatable pattern twice does not create duplicates β BullMQ keys them by name + pattern. To change or stop a schedule, remove the old one.
// List what's scheduled
const schedulers = await reportQueue.getJobSchedulers();
// Remove a repeatable job by its scheduler id
await reportQueue.removeJobScheduler('daily-summary');
β node-cron or BullMQ repeatable?
- node-cron β single instance, lightweight, the task is quick and it's fine to skip a missed run.
- BullMQ repeatable β multiple instances, needs retries/observability, must not double-fire, and should survive restarts.
For most production apps that already run BullMQ, put scheduled work on it too β one system to monitor, and the double-firing problem solves itself.
Idempotency, Locking & Time Zones
Scheduled tasks fail in three classic ways. Guard against all three and yours will run reliably for years.
1. Idempotency
A schedule can misfire, overlap, or be retried β so a task must be safe to run more than once for the same period. Stamp the work with the date and check before acting.
// β
Idempotent daily newsletter β skips anyone already sent today
async function sendDailyNewsletter() {
const today = new Date().toISOString().slice(0, 10); // "2026-07-31"
const users = await User.findSubscribedWithoutSendOn('daily', today);
for (const user of users) {
await mailer.send(user.email, 'daily-newsletter');
await SendLog.record({ userId: user.id, type: 'daily', date: today });
}
}
2. Prevent overlapping runs
If a task occasionally takes longer than its interval, the next tick can start before the last finished β two copies clobbering each other. A short-lived Redis lock ensures only one runs at a time.
async function runWithLock(key, ttlSeconds, task) {
// SET key value NX EX ttl β succeeds only if the key doesn't exist.
const acquired = await redis.set(key, '1', 'NX', 'EX', ttlSeconds);
if (!acquired) {
console.log('Another run holds the lock β skipping this tick');
return;
}
try {
await task();
} finally {
await redis.del(key); // release even if the task throws
}
}
cron.schedule('*/5 * * * *', () => runWithLock('sync-lock', 300, syncData));
This same lock trick lets you run node-cron across several instances safely: all three fire, but only the one that grabs the lock actually works.
3. Time zones
Servers usually run in UTC, but "9 AM" means a wall-clock time to your user. Always specify a timezone/tz for user-facing schedules, and store timestamps in UTC. Don't assume the server's local zone.
β οΈ Always wrap the task body in try/catch
An unhandled rejection inside a scheduled callback can crash the whole process β taking down the web server if cron runs in-process. Catch errors, log them, record a metric, and alert. A scheduled task that fails silently is worse than one that never ran, because you won't know until the missing report is noticed.
cron.schedule('0 0 * * *', async () => {
const start = Date.now();
try {
await performBackup();
console.log(`Backup ok in ${Date.now() - start}ms`);
} catch (err) {
console.error('Backup FAILED:', err);
await alerts.notify('Nightly backup failed', err);
}
});
Practice & Quiz
ποΈ Exercise 1: Translate the schedule
Goal: Write cron expressions for each of these:
- Every day at 2:30 AM
- Every 10 minutes
- Every Monday at 9:00 AM
- Midnight on the first day of every month
π‘ Hint
Fields are minute hour day-of-month month day-of-week. Monday is 1. Use */n for steps.
β Solution
1. 30 2 * * * # 2:30 AM daily
2. */10 * * * * # every 10 minutes
3. 0 9 * * 1 # 9:00 AM every Monday
4. 0 0 1 * * # midnight on the 1st of each month
ποΈ Exercise 2: Move a cron job onto BullMQ
Goal: This node-cron job runs on every instance and double-sends when scaled. Convert it to a BullMQ repeatable job (producer side) so it fires once regardless of instance count. Keep the schedule: 8 AM daily, New York time.
cron.schedule('0 8 * * *', () => sendMorningDigest(), {
timezone: 'America/New_York'
});
β Solution
import { Queue } from 'bullmq';
import { connection } from './queues/connection.js';
const digestQueue = new Queue('digest', { connection });
await digestQueue.add(
'morning-digest',
{},
{
repeat: { pattern: '0 8 * * *', tz: 'America/New_York' },
removeOnComplete: 50,
removeOnFail: 200
}
);
// A single Worker (in its own process) runs sendMorningDigest(),
// and BullMQ enqueues exactly one run per day no matter how many
// API instances registered the repeatable job.
π― Quick Quiz
Question 1: What does the cron expression 0 0 * * 0 mean?
Question 2: You scale your app to 3 instances. Your node-cron newsletter job nowβ¦
Question 3: Why prefer a BullMQ repeatable job for a critical nightly task?
Best Practices & Pitfalls
β Do
- Keep the task separate from the schedule so you can test the logic
- Make every scheduled task idempotent β stamp work with a date and check first
- Specify a time zone for user-facing schedules; store timestamps in UTC
- Wrap task bodies in try/catch, then log, record metrics, and alert on failure
- Use a lock (or a queue) to prevent overlapping and multi-instance double-runs
β Don't
- Don't rely on in-process node-cron for critical jobs across multiple instances
- Don't let an unhandled rejection in a cron callback crash your server
- Don't assume the server's local time β be explicit about zones
- Don't schedule heavy work to all run at exactly midnight; stagger to avoid a thundering herd
π‘ Scheduling in serverless
If you deploy to serverless (Vercel, AWS Lambda), there's no long-running process to hold a node-cron timer. Use the platform's scheduler instead β Vercel Cron, AWS EventBridge, or Google Cloud Scheduler β to hit an endpoint on a cron schedule. The cron syntax you learned here is exactly what those services accept.
Summary
π Key Takeaways
- Scheduled tasks are background jobs whose trigger is time; everything about workers and retries still applies
- A five-field cron expression β
minute hour day-of-month month day-of-weekβ is a portable schedule language - node-cron runs schedules in-process: simple, but dies with the process and double-fires when replicated
- BullMQ repeatable jobs store the schedule in Redis β durable, shared across instances, and retryable
- Reliable schedules need idempotency, locking against overlap, explicit time zones, and error handling
π Additional Resources
- node-cron β Official documentation
- BullMQ β Repeatable jobs guide
- crontab.guru β Cron expression explainer
- Vercel Cron Jobs (serverless scheduling)
π What's Next?
You've now completed Week 10's background-jobs arc: queues, workers, and schedules. Next you'll put the whole stack together in the weekend project β Build a Full-Stack Social Media Application β where background jobs power the feed fan-out, notifications, and image processing you just learned to run off the request path.
π Nicely done!
You can schedule anything now β from a five-minute health check to a monthly invoice run β reliably and on time.