---
title: 'Scheduled Tasks'
source: 'https://academia.sh/en/courses/asynchronous-processing/scheduled-tasks'
course: 'Caching, Queues and Asynchronous Processing'
language: en
updated: '2026-08-23T07:00:22+00:00'
license: 'CC BY-SA 4.0'
---

# Scheduled Tasks

The definition and hazards of calendar-triggered jobs: computing the recurrence rule, the end-of-month and time-zone traps, the fixed-rate versus fixed-delay distinction, and a measured solution that prevents two runs from overlapping with a lease lock.

The previous lesson took jobs from outside: the pool held a list and distributed it to
workers. An important share of background jobs, though, do not come from outside — they
come from the **calendar**. The overdue notification runs every morning at six, the
branch inventory summary every fifteen minutes, the monthly loan report on the first of
every month.

Summing this up as "set a timer and call the job" is misleading. A scheduled task has
two hard questions. First, how recurrence is defined: what happens when "the 31st of
every month" lands on a date that does not exist, does a run get skipped when the local
clock changes. Second, overlap: if one run does not finish before the next trigger, two
runs execute at once and produce the same report twice. This lesson builds both, and
solves the second by measuring it.

## Recurrence Definition

A recurring task has two parts: **what** gets done and **when** it gets done. The
second is a data structure, and it can be tested with a single function: "what is the
next run instant from right now."

```js
// schedule.mjs — computes the upcoming run instants from a recurring task definition (all UTC)
const DEFINITIONS = {
  overdueNotification: { type: "daily", hour: 6, minute: 0 },
  inventorySummary: { type: "interval", minute: 15 },
  monthlyReport: { type: "monthly", day: 1, hour: 2, minute: 30 },
  endOfMonth: { type: "monthly", day: 31, hour: 2, minute: 30 },
};

const MIN = 60_000;

function nextRun(definition, now) {
  const d = new Date(now);
  if (definition.type === "interval") {
    const step = definition.minute * MIN;
    return new Date(Math.floor(now / step) * step + step);
  }
  if (definition.type === "daily") {
    const today = Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate(),
                           definition.hour, definition.minute);
    return new Date(today > now ? today : today + 24 * 60 * MIN);
  }
  // monthly: if this month's day has passed, the same day next month
  const thisMonth = Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), definition.day,
                        definition.hour, definition.minute);
  return new Date(thisMonth > now ? thisMonth
    : Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + 1, definition.day, definition.hour, definition.minute));
}

const START = Date.parse("2026-01-31T23:47:00Z");   // fixed starting instant for the measurement
for (const [name, definition] of Object.entries(DEFINITIONS)) {
  let at = START;
  const moments = [];
  for (let i = 0; i < 3; i++) {
    at = nextRun(definition, at).getTime();
    moments.push(new Date(at).toISOString().slice(0, 16).replace("T", " "));
  }
  console.log(name.padEnd(20), moments.join("  "));
}
```

```
overdueNotification  2026-02-01 06:00  2026-02-02 06:00  2026-02-03 06:00
inventorySummary     2026-02-01 00:00  2026-02-01 00:15  2026-02-01 00:30
monthlyReport        2026-02-01 02:30  2026-03-01 02:30  2026-04-01 02:30
endOfMonth           2026-03-03 02:30  2026-03-31 02:30  2026-05-01 02:30
```

Because the starting instant is written as a fixed constant, the output is the same on
every run; this turns the scheduling rule into a testable function. Supplying "now"
from outside the function is the one decision that makes scheduling testable.

The first three lines are as expected. The fourth line is **not**.

### The End-of-Month Trap

The `endOfMonth` definition says "the 31st of every month at 02:30." The instants
produced are March 3, March 31, and May 1. February has no 31st; `Date.UTC(2026, 1,
31)` carries the overflowing day into the next month and produces March 3. April has no
31st either, so it slides to May 1.

This is not a bug in the computation, it is a **gap in the definition**. For a task tied
to the last day of the month, a choice has to be made between two policies: **skip** the
nonexistent day (no run that month) or **clamp** to the last actual day of the month
(the 28th, 29th, or 30th). Which one is correct depends on the job — a monthly report
should clamp, while a subscription renewal ending on the 31st should not be skipped. A
policy that is never written down is a policy handed over to the runtime's overflow
behavior.

### Time Zone

The whole computation above ran on UTC. A task tied to local time produces two further
problems. On the night the clock jumps forward for daylight saving time, local 02:30
**never happens**; the run for that night is skipped. On the night it jumps back, local
02:30 **happens twice**; the run fires twice.

This is why the scheduling decision splits into two layers. The task's trigger instant
is kept and stored in UTC; local time is used only when displaying it to a user. If a
local calendar boundary is genuinely required (for example, "after the branch closes"),
the time zone identity becomes part of the definition and the conversion is redone on
every run.

## Fixed Rate and Fixed Delay

The second detail of recurrence is what the next run is scheduled against.

In **fixed-rate** scheduling, runs are pinned to the calendar: 02:30, 03:30, 04:30. No
matter how long a run takes, the next one's instant does not move. In **fixed-delay**
scheduling, the wait is counted from the **end** of the previous run: if a run took 40
minutes and the delay is one hour, the next run starts at 04:10.

The difference between them is drift. An hourly task that takes forty minutes runs
about 14 times a day under fixed delay instead of 24, and its run instants creep
forward every day; this creeping is called **drift**. Fixed rate has no drift, but in
exchange it makes overlap between runs possible: if a run takes longer than an hour, the
next trigger arrives while the previous one is still running.

So the choice between fixed rate and fixed delay is a choice between drift and overlap.
If drift cannot be tolerated, overlap has to be prevented separately.

## Overlap

Overlap does not only arise from a long run. A second, and more commonly encountered,
source is the application being deployed as **multiple instances**. If the scheduler
runs inside the application, a service deployed across three instances sends the
overdue notification three times every morning at six. A third source is redeployment:
if the new instance comes up before the old one shuts down, the trigger doubles.

The fix for all three is the same: a task must **acquire the right to run** before it
runs.

## Lease Lock

The structure that grants this right is called a **lock**. The form used for
background tasks is the **lease lock**: the lock is not granted indefinitely but for a
fixed duration. The duration is necessary, because if the process holding the lock
crashes, there is no one left to release it; the lease lets the lock drop on its own in
that case.

The correctness of the lock depends on a single condition: acquisition must be
**atomic**. If two runs both ask "is there a lock" and both get the answer "no," the
lock is useless. This is why the check and the write are combined into a single
statement.

```js
// task.mjs — the recurring task that generates the monthly loan report
//   node task.mjs <label>           run without a lock
//   node task.mjs <label> --lock    run with a lease lock
import { DatabaseSync } from "node:sqlite";
import { appendFileSync } from "node:fs";

const LABEL = process.argv[2];
const LOCKED = process.argv.includes("--lock");
const LEASE_MS = 5000;                             // the lock's lease duration

const db = new DatabaseSync("schedule.db");
db.exec("PRAGMA busy_timeout = 5000");
db.exec(`CREATE TABLE IF NOT EXISTS lock (
           name TEXT PRIMARY KEY, owner TEXT NOT NULL, expires INTEGER NOT NULL)`);

// Acquiring the lock is a single atomic write: the row is created if absent, or taken
// over if present but its lease has expired. If two runs try at once, only one changes the row.
const acquireLock = (name, owner) => {
  const now = Date.now();
  return db.prepare(
    `INSERT INTO lock (name, owner, expires) VALUES (?, ?, ?)
       ON CONFLICT(name) DO UPDATE SET owner = excluded.owner, expires = excluded.expires
       WHERE lock.expires < ?`).run(name, owner, now + LEASE_MS, now).changes === 1;
};
const releaseLock = (name, owner) =>
  db.prepare("DELETE FROM lock WHERE name = ? AND owner = ?").run(name, owner);

function generateReport() {
  appendFileSync("trace.log", `started ${LABEL}\n`);
  const deadline = Date.now() + 300;                // report generation takes 300 ms
  while (Date.now() < deadline);
  appendFileSync("trace.log", `done ${LABEL}\n`);
}

if (!LOCKED) generateReport();
else if (acquireLock("monthly-report", LABEL)) {
  try { generateReport(); } finally { releaseLock("monthly-report", LABEL); }
} else {
  appendFileSync("trace.log", `skipped ${LABEL}\n`);
}
```

Three details are what make the lock correct. The lock's **name** is the task's name,
not the process name; different instances use the same name. Releasing the lock also
filters on the `owner` column, so a run whose lock was taken over because its lease
expired cannot delete someone else's lock. And the release sits inside `finally`, so it
runs on failure too.

## Measurement

Whether the lock works is not asserted, it is measured. The driver below starts the
same task twice at the exact same time and computes the **peak concurrent run count**
from the started/done markers in `trace.log`.

```js
// driver.mjs — starts the same task twice at once and parses the trace
import { spawn } from "node:child_process";
import { writeFileSync, readFileSync, rmSync } from "node:fs";

const spawnRun = (argv) =>
  new Promise((c) => spawn("node", argv, { stdio: "ignore" }).on("exit", c));

function parseTrace() {
  let open = 0, peak = 0, runs = 0, skipped = 0;
  for (const line of readFileSync("trace.log", "utf8").trim().split("\n")) {
    if (line.startsWith("started")) { open++; runs++; peak = Math.max(peak, open); }
    else if (line.startsWith("done")) open--;
    else skipped++;
  }
  return { runs, skipped, peak };
}

for (const mode of ["unlocked", "locked"]) {
  writeFileSync("trace.log", "");
  rmSync("schedule.db", { force: true });
  const extra = mode === "locked" ? ["--lock"] : [];
  await Promise.all([
    spawnRun(["task.mjs", "A", ...extra]),
    spawnRun(["task.mjs", "B", ...extra]),
  ]);
  const { runs, skipped, peak } = parseTrace();
  console.log(`${mode.padEnd(9)} runs=${runs} skipped=${skipped} concurrent-peak=${peak}`);
}
```

```
unlocked  runs=2 skipped=0 concurrent-peak=2
locked    runs=1 skipped=1 concurrent-peak=1
```

In unlocked mode both runs generate the report and the peak comes out 2: the monthly
loan report has been computed twice. In locked mode, runs is 1, skipped is 1, and peak
is 1. The second run exits without doing the work because it cannot acquire the lock.

Notice that a skipped run is **not counted as an error**. Failing to acquire the lock
is an expected outcome for scheduled tasks; it is logged but raises no alert. What does
raise an alert is the same task going a long time without ever running.

## Missed Run

The lock prevents overlap, but it leaves a question open: if a run could not happen at
all because of the lock or because the service was down, does it get made up for later?

There are two policies. Under the **no-catch-up** policy, a missed run is forgotten; the
next trigger performs a normal run. Tasks that compute a final state, like the branch
inventory summary, fit this, because the new run already covers the result of the
missed one. Under the **catch-up** policy, a separate run is produced for every missed
interval. The monthly loan report fits this, because each month's report is a separate
output and nothing replaces it if one is skipped.

The danger of the catch-up policy is that every run accumulated during a long outage
fires all at once. This is why catch-up is given an upper bound: at most the last N
intervals are made up, the rest fall into the log.

## Summary

- The recurring task definition reduces to a "next run instant from right now" function; taking "now" as an input from outside makes the schedule testable.
- A definition tied to a nonexistent day of the month overflows into the next month; a skip or clamp-to-last-day policy must be written down explicitly.
- Fixed rate prevents drift but is open to overlap; fixed delay prevents overlap but shifts the run instants.
- Overlap arises from a long run, a multi-instance deployment, and redeployment; the fix for all three is a lease lock acquired before the run starts.
- The lock's correctness depends on the atomicity of acquisition, and its durability on the lease expiring on its own; the measurement brought the peak concurrent run count down from 2 to 1.

## Next Step

The tasks in this lesson were short: a report that finishes in three hundred
milliseconds. The lease duration could be chosen accordingly. But a share of background
jobs run for minutes — a monthly report scanning thirty thousand loan records,
regenerating an uploaded cover image at several sizes. Once a job like that starts, two
questions follow: how does the party that started it learn how far it has gotten, and
where does the job stop when it is abandoned. The next lesson builds progress
reporting and cancellation for long-running jobs.
