Skip to content
Français

Schedule recurring runs

Publish workflow jobs on a cron schedule with an explicit time zone.

Create a schedule with createCronSchedule() and give it a cron expression and time zone. runSchedules() publishes a queue job at each matching time until you abort its signal; a worker runs the job separately.

import { createCronSchedule } from "@elie-laloum/outpost";

export const timeZone = "Europe/Paris";
export const day = (slot: Date) =>
  slot.toLocaleDateString("en-CA", { timeZone });
export const schedules = [
  {
    name: "nightly-audit",
    cron: createCronSchedule("0 2 * * 1-5", { timeZone }),
    handler: "audit",
    runId: (slot: Date) => `audit-${day(slot)}`,
    input: (slot: Date) => ({ day: day(slot) }),
  },
];
import { createSqliteTaskQueue, runSchedules } from "@elie-laloum/outpost";
import { schedules } from "./audit-schedule.ts";

export const queue = await createSqliteTaskQueue(".outpost/jobs.sqlite");
export const stop = new AbortController();
process.once("SIGINT", () => stop.abort());
try {
  await runSchedules({ queue, signal: stop.signal, schedules });
} finally {
  queue.close();
}

At 02:00 Paris time, Monday to Friday, the scheduler publishes a job for the audit handler with a runId such as audit-2026-09-30. Ctrl+C aborts the signal and runSchedules() resolves.

node scheduler.ts

API reference: CronOptions.

A worker opens the same queue and registers audit with defineWorkflowJob(), which runs one checkpointed workflow per runId: see Job queues and workers. The Nightly maintenance recipe shows the scheduler and the worker together.

An expression has five fields separated by spaces: minute, hour, day of month, month, day of week.

SyntaxExampleFires
Value, *30 2 * * *At 02:30 every day; * matches any value.
List0 9,18 * * *At 09:00 and 18:00.
Range0 9 * * 1-5At 09:00, Monday to Friday.
Step0 8-18/2 * * *At 08:00, 10:00, …, 18:00. */15 in the minute field means every 15 minutes.
Names0 9 1 JAN,JUL *At 09:00 on 1 January and 1 July. Names JAN–DEC and SUN–SAT ignore case; 0 and 7 are Sunday.
Macro@dailyAt 00:00 every day. Also @hourly, @midnight, @weekly, @monthly, @yearly and @annually.
Both days0 9 1 * MONAt 09:00 on the 1st of the month and on every Monday, as in Vixie cron.

This union applies only when neither day field starts with *; otherwise a day must match both fields. There is no seconds field.

slot.toISOString().slice(0, 10) gives the UTC date. At 01:00 in Paris, that is still the previous day.

Use slot.toLocaleDateString("en-CA", { timeZone }) with the schedule’s time zone, as in the first snippet, to get the local date as YYYY-MM-DD.

Slots are wall-clock times in the schedule’s time zone.

  • Skipped time: A time that does not exist on the spring-forward day does not fire that day.
  • Repeated time: A time that occurs twice on the fall-back day fires once, at its first occurrence.

Each slot publishes the job schedule:<name>:<slot ISO time>. A restarted scheduler, or several replicas sharing one queue, publish the same job ID, and the queue keeps a single job.

runId and input must depend only on the slot. The queue rejects a second publication of the same ID with a different request.

Two schedules can return the same runId for one day: the second job then reuses the same checkpointed run if its input is the same (a different input fails with an incompatible checkpoint), like the 07:00 resume in Nightly maintenance.

A slot is published only if at most maxLateMs has passed since it (60 000 ms by default). After a restart or a suspended process, only the latest missed slot within that window is published; older ones are skipped.

Raise maxLateMs to catch up a slot missed during a longer outage, for example maxLateMs: 6 * 60 * 60_000 for six hours.

Without onError, the first failed publication stops every schedule and rejects runSchedules(). With it, you receive the error with the schedule name and slot, and scheduling continues with the next slot.

import { runSchedules } from "@elie-laloum/outpost";
import type { TaskQueue, TriggerSchedule } from "@elie-laloum/outpost";

function schedule(
  queue: TaskQueue,
  schedules: TriggerSchedule[],
  signal: AbortSignal,
) {
  return runSchedules({
    queue,
    schedules,
    signal,
    onError: (error, { schedule, slot }) =>
      console.error(`${schedule} ${slot.toISOString()}`, error),
  });
}

A failed slot is not published again. Errors thrown by onError are ignored.

next(after) returns the first slot strictly after a date, previous(at) the latest slot at or before it. Neither publishes anything.

import { reportValue } from "./reporter.ts";
import { createCronSchedule } from "@elie-laloum/outpost";

const nightly = createCronSchedule("30 2 * * *", { timeZone: "Europe/Paris" });
reportValue(nightly.next(new Date("2026-03-28T12:00:00Z")).toISOString());
// Example output: 2026-03-30T00:30:00.000Z

This prints 2026-03-30T00:30:00.000Z: 02:30 does not exist in Paris on 29 March 2026.

createCronSchedule() throws on an invalid field, an unknown time zone or an expression with no occurrence, such as 0 0 30 2 *.

Without a long-running process, a scheduled CI job, such as a GitHub Actions schedule workflow, can start the workflow directly with a checkpoint. See Run in CI.

  • The finest resolution is one minute.
  • After downtime, only the latest missed slot within maxLateMs is published.
  • A schedule name is unique and uses letters, digits, ., _ and - (128 characters at most). A runId has at most 256 characters.

API: createCronSchedule · runSchedules · TriggerSchedule · CronSchedule · defineWorkflowJob.