hectoday
DocsCoursesChangelog GitHub
DocsCoursesChangelog GitHub

Access Required

Enter your access code to view courses.

Invalid code

← All courses Background Jobs and Queues with @hectoday/http

Why Background Jobs

  • The Request Cycle Problem
  • Project Setup

Building a Queue

  • Database-Backed Queues
  • The Worker Loop
  • Job Serialization

Reliability

  • Retries and Backoff
  • Dead Letter Queues
  • Idempotent Jobs
  • Job Timeouts and Stale Jobs

Scheduling

  • Delayed Jobs
  • Recurring Jobs (Cron)

Scaling

  • Concurrency and Locking
  • Job Priorities
  • Rate-Limiting Jobs

Patterns

  • Job Chaining and Workflows
  • Monitoring and Observability

Putting It All Together

  • Capstone: Order Processing Pipeline

Recurring Jobs (Cron)

Jobs that repeat

“Generate a sales report every day at midnight.” “Clean up expired sessions every hour.” “Send a weekly digest every Monday at 9 AM.” These are recurring jobs — they run on a schedule, repeatedly.

A cron schedule table

Recurring jobs are not individual job rows — they are schedule definitions that create jobs:

CREATE TABLE IF NOT EXISTS cron_schedules (
  id TEXT PRIMARY KEY,
  type TEXT NOT NULL,
  payload TEXT NOT NULL DEFAULT '{}',
  cron_expression TEXT NOT NULL,
  last_run_at TEXT,
  next_run_at TEXT NOT NULL,
  is_active INTEGER NOT NULL DEFAULT 1,
  created_at TEXT NOT NULL DEFAULT (datetime('now'))
);

Each row defines a recurring job: what to run (type, payload), when to run it (cron_expression, next_run_at), and when it last ran (last_run_at).

Cron expressions

A cron expression defines a schedule using five fields:

┌───── minute (0-59)
│ ┌───── hour (0-23)
│ │ ┌───── day of month (1-31)
│ │ │ ┌───── month (1-12)
│ │ │ │ ┌───── day of week (0-6, 0 = Sunday)
│ │ │ │ │
* * * * *

Common patterns: 0 0 * * * (midnight daily), 0 * * * * (every hour), 0 9 * * 1 (Monday 9 AM), */15 * * * * (every 15 minutes).

A simple cron parser

For this course, we use a simplified approach — computing the next run time:

// src/cron.ts
export function computeNextRun(expression: string, after: Date = new Date()): Date {
  const [minute, hour, dayOfMonth, month, dayOfWeek] = expression.split(" ");

  // Simple implementation for common patterns
  const next = new Date(after);
  next.setSeconds(0, 0);

  if (expression === "0 0 * * *") {
    // Daily at midnight
    next.setDate(next.getDate() + 1);
    next.setHours(0, 0, 0, 0);
  } else if (expression === "0 * * * *") {
    // Every hour
    next.setHours(next.getHours() + 1);
    next.setMinutes(0, 0, 0);
  } else if (expression.startsWith("*/")) {
    // Every N minutes
    const interval = parseInt(expression.split(" ")[0].replace("*/", ""));
    const currentMinute = next.getMinutes();
    const nextMinute = Math.ceil((currentMinute + 1) / interval) * interval;
    next.setMinutes(nextMinute, 0, 0);
  } else {
    // Fallback: run in 1 hour
    next.setHours(next.getHours() + 1);
  }

  return next;
}

[!TIP] For production cron parsing, use a library like cron-parser. The simplified version above handles the most common patterns. The concept is the same: convert a cron expression into the next concrete datetime.

The cron scheduler

A function that runs periodically, checks which cron schedules are due, and enqueues their jobs:

// src/cron-scheduler.ts
export function runDueCronJobs(): number {
  const due = db
    .prepare(
      `
    SELECT * FROM cron_schedules
    WHERE is_active = 1 AND next_run_at <= datetime('now')
  `,
    )
    .all() as CronSchedule[];

  for (const schedule of due) {
    // Enqueue the job
    enqueue(schedule.type, JSON.parse(schedule.payload));

    // Update last_run_at and compute next_run_at
    const nextRun = computeNextRun(schedule.cron_expression);
    db.prepare(
      `
      UPDATE cron_schedules
      SET last_run_at = datetime('now'),
          next_run_at = ?
      WHERE id = ?
    `,
    ).run(nextRun.toISOString(), schedule.id);
  }

  return due.length;
}

Run this in the worker loop alongside job processing:

// In the worker loop
let lastCronCheck = 0;
const CRON_CHECK_INTERVAL = 60_000; // Check every minute

while (running) {
  // Check cron schedules every minute
  if (Date.now() - lastCronCheck > CRON_CHECK_INTERVAL) {
    const count = runDueCronJobs();
    if (count > 0) console.log(`[CRON] Enqueued ${count} scheduled jobs`);
    lastCronCheck = Date.now();
  }

  // Process jobs as usual
  const job = dequeue(WORKER_ID);
  // ... same as before
}

Preventing duplicate runs

If two workers check cron schedules at the same time, they might both enqueue the same job. Use the same atomic claim pattern from the dequeue query:

export function runDueCronJobs(workerId: string): number {
  // Atomically claim and update each due schedule
  const claimAndUpdate = db.prepare(`
    UPDATE cron_schedules
    SET last_run_at = datetime('now'), next_run_at = ?
    WHERE id = ? AND next_run_at <= datetime('now') AND is_active = 1
  `);

  const due = db
    .prepare(
      `
    SELECT * FROM cron_schedules
    WHERE is_active = 1 AND next_run_at <= datetime('now')
  `,
    )
    .all() as CronSchedule[];

  let count = 0;
  for (const schedule of due) {
    const nextRun = computeNextRun(schedule.cron_expression);
    const result = claimAndUpdate.run(nextRun.toISOString(), schedule.id);

    if (result.changes > 0) {
      enqueue(schedule.type, JSON.parse(schedule.payload));
      count++;
    }
    // If changes === 0, another worker already claimed this schedule
  }

  return count;
}

Registering cron schedules

// At startup or via an admin endpoint
function registerCron(
  type: string,
  expression: string,
  payload: Record<string, unknown> = {},
): void {
  const nextRun = computeNextRun(expression);
  db.prepare(
    `
    INSERT OR IGNORE INTO cron_schedules (id, type, payload, cron_expression, next_run_at)
    VALUES (?, ?, ?, ?, ?)
  `,
  ).run(type, type, JSON.stringify(payload), expression, nextRun.toISOString());
}

// Register schedules
registerCron("daily_sales_report", "0 0 * * *");
registerCron("cleanup_expired_sessions", "0 * * * *");
registerCron("weekly_digest", "0 9 * * 1", { template: "weekly" });

Exercises

Exercise 1: Register a cron schedule that runs every minute. Watch the worker enqueue and process the job.

Exercise 2: Run two workers simultaneously. Verify each cron job is enqueued only once (not duplicated).

Exercise 3: Disable a cron schedule (is_active = 0). Verify it stops producing jobs.

Why does the cron scheduler enqueue jobs into the regular jobs table instead of running them directly?

← Delayed Jobs Concurrency and Locking →

© 2026 hectoday. All rights reserved.