Skip to main content

⏰ 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.

CategoryExample scheduled tasks
MaintenanceDelete expired sessions, rotate logs, back up the database
Data processingAggregate yesterday's analytics, rebuild search indexes
EngagementDaily digests, weekly newsletters, "we miss you" emails
BusinessMonthly 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.

graph LR Clock(["⏱️ Schedule fires"]) --> Task["Run task"] Task --> S{Succeeded?} S -->|Yes| Log["Log & record metrics"] S -->|No| Retry["Retry or alert"]

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)
β”‚ β”‚ β”‚ β”‚ β”‚
* * * * *
The five cron fields: minute, hour, day of month, month, and day of week * * * * * minute hour day of month month day of week
Read left to right: minute, hour, day-of-month, month, day-of-week. A * 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-5 is 9 AM Monday through Friday.
  • / β€” a step. */15 * * * * is every 15 minutes.

Expressions you'll actually use

ExpressionMeaning
* * * * *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-59:00 AM on weekdays
0 10 * * 110: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.

graph LR Sched["Repeatable job (cron)"] -->|fires| R[("Redis")] R -->|enqueues run| Q["waiting"] Q --> W["Worker processes it"] W --> Done["completed with retries"]
// 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:

  1. Every day at 2:30 AM
  2. Every 10 minutes
  3. Every Monday at 9:00 AM
  4. 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

πŸš€ 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.