Aviary
Authoring

Scheduling

Recurring workflows with ScheduledWorkflow — fixed intervals via everyMs or DST-aware cron via cron + timezone — fired each tick by the NestJS module's schedules option, started exactly once per window by an idempotent time-bucket run id.

A scheduled workflow is just a normal workflow that the engine starts on a recurring cadence for you — a nightly report, an hourly sync, a cleanup every five minutes. You describe the cadence with a ScheduledWorkflow, hand it to the module's schedules option, and the engine fires it each tick. The interesting part is the exactly-once guarantee: even with several worker instances racing on the same tick, each schedule window starts exactly once.

Describing a schedule — ScheduledWorkflow

interface ScheduledWorkflow {
  /** Stable key identifying this schedule — part of the deterministic run id. */
  key: string;
  workflow: string;
  input?: unknown;
  /** Start one run every `everyMs`. Mutually exclusive with `cron`. */
  everyMs?: number;
  /** A cron expression evaluated in `timezone`. Mutually exclusive with `everyMs`. */
  cron?: string;
  /** IANA timezone the `cron` fires in (e.g. `America/Sao_Paulo`). Defaults to UTC. */
  timezone?: string;
  /** Temporarily stop firing this schedule (kept registered). Defaults to false. */
  paused?: boolean;
  /** What to do when the previous window's run hasn't finished (fixed-interval only): `'allow'`
   *  (default) starts the new window anyway; `'skip'` skips it while the prior run is still
   *  `running`/`suspended`. */
  overlap?: 'allow' | 'skip';
  /** Randomly delay dispatch by up to this many ms, to spread load across instances ticking the
   *  same boundary. Opt-in; absent = fire immediately. */
  jitter?: number;
  /** Enqueue windows missed while the scheduler was down, instead of silently skipping them.
   *  `maxCatchup` bounds how many prior windows are backfilled. Opt-in; absent = only the current
   *  window fires. */
  backfill?: { maxCatchup: number };
}

Every schedule has a stable key, the workflow to start, and an optional input. The cadence is either everyMs (a fixed interval) or cron + timezone (a calendar expression) — they are mutually exclusive. The rest of the fields tune what happens around a fire: paused to stop it temporarily, overlap to guard against pile-up, jitter to spread load, and backfill to catch up on windows missed during downtime — each covered below.

Wiring it up — the schedules option

Pass your schedules to DurableModule. The durable-timer poller fires them each tick on worker instances:

DurableModule.forRoot({
  store,
  transport,
  schedules: [
    // Run the `daily-report` workflow at 02:00 in São Paulo — DST-aware.
    {
      key: 'daily-report',
      workflow: 'daily-report',
      cron: '0 2 * * *',
      timezone: 'America/Sao_Paulo',
    },
    // Run the `cache-sync` workflow every five minutes.
    {
      key: 'cache-sync',
      workflow: 'cache-sync',
      everyMs: 5 * 60 * 1000,
    },
  ],
});

Each schedule's workflow field is the @Workflow name it starts. There's no schedule option on @Workflow itself — schedules are deployment config (which cadence runs where), kept separate from the workflow definition, so the same workflow can be scheduled differently per environment or started ad-hoc. The daily-report schedule above starts this ordinary provider:

@Workflow({ name: 'daily-report', version: '1' })
export class DailyReportWorkflow {
  constructor(private readonly reports: ReportService) {}
  async run(ctx: WorkflowCtx) {
    const rows = await ctx.step(this.reports.gatherYesterday, undefined);
    await ctx.step(this.reports.email, rows);
    return { rows: rows.length };
  }
}

The workflow body itself is ordinary — it doesn't know it's scheduled. The engine starts it on the cadence; from there it's just steps that checkpoint and resume like any other run:

1@Workflow({ name: 'daily-report', version: '1' })2export class DailyReportWorkflow {3  constructor(private readonly reports: ReportService) {}4  async run(ctx: WorkflowCtx) {5    const rows = await ctx.step(this.reports.gatherYesterday, undefined);6    await ctx.step(this.reports.email, rows);7    return { rows: rows.length };8  }9}
run settles — completedtickgatheremaildone
completesThe body returns the row count and the run completes. Next tick opens a new time-bucket window with a fresh run id; this one is done.
4 / 4

Fixed intervals — everyMs

everyMs starts one run every interval. The current window is identified by the time bucket floor(now / everyMs), which becomes part of the run id (sched:<key>:<bucket>). Every tick inside the same window resolves to the same run id, so re-firing the schedule within a window is a no-op.

{ key: 'cache-sync', workflow: 'cache-sync', everyMs: 5 * 60 * 1000 } // every 5 minutes

Use a fixed interval when "roughly every N" is what you want and you don't care about wall-clock alignment to the calendar.

Cron — cron + timezone

For calendar-aligned schedules, use a cron expression evaluated in an IANA timezone. The expression is the standard 5 fields (m h dom mon dow), or 6 with a leading seconds field. Because it is evaluated in the named timezone, it is DST-aware0 2 * * * in America/Sao_Paulo fires at 02:00 local regardless of daylight-saving shifts, not at a fixed UTC offset.

{ key: 'nightly-report', workflow: 'daily-report', cron: '0 2 * * *', timezone: 'America/Sao_Paulo' }

The window for a cron schedule is its most recent fire time at or before now — the deterministic "bucket" the run is keyed on (sched:<key>:<prevFireMs>). Polling repeatedly between two fires resolves to the same bucket, and thus the same run id, so the run for a given fire starts once.

Cron needs the optional peer dependency cron-parser — core stays dependency-free, and only users who schedule by cron pull it in. Install it where you run the engine:

npm i cron-parser

Omitting it and using a cron schedule throws a clear error pointing you to install it.

Pausing a schedule — paused

Set paused: true to stop a schedule from firing without removing it from your config. It stays registered — the dashboard still lists it, and un-pausing it later just resumes ticking — but the poller skips it entirely on every tick while paused:

{ key: 'nightly-report', workflow: 'daily-report', cron: '0 2 * * *', paused: true }

Skipping overlapping runs — overlap

By default (overlap: 'allow'), a fixed-interval schedule starts every window on schedule even if the previous window's run is still going — a slow run and a fresh one can end up in flight together. Set overlap: 'skip' to skip a window instead, while the immediately preceding window's run is still running or suspended:

{ key: 'cache-sync', workflow: 'cache-sync', everyMs: 60_000, overlap: 'skip' }

This is fixed-interval only (it checks the previous everyMs bucket) — cron windows aren't adjacent ids in the same way, so overlap has no effect on a cron schedule.

Dispatch jitter — jitter

When many instances tick on the same boundary, they all try to start the same window at once. jitter randomly delays the dispatch by up to that many ms before firing, spreading the load instead of every instance hitting the store in the same instant:

{ key: 'cache-sync', workflow: 'cache-sync', everyMs: 60_000, jitter: 5_000 } // fire within 5s of the tick

Jitter only delays when an instance attempts the dispatch — it does not change the run id (still the time bucket), so idempotency is unchanged: however many instances jitter the same window, it still starts exactly once.

Catching up on missed windows — backfill

If the process running the poller is down when a window would have fired, that window is missed — by default, only the current window fires the next time the poller runs. Set backfill to enqueue the windows that were missed instead:

{ key: 'hourly-rollup', workflow: 'hourly-rollup', everyMs: 60 * 60 * 1000, backfill: { maxCatchup: 6 } }

maxCatchup bounds how many prior windows are backfilled, so a long outage can't flood the system with a backlog — at most maxCatchup missed windows start, oldest first, followed by the current one. Backfilled runs use each missed window's own deterministic bucket run id, so if some of them already ran (e.g. another instance caught up first), engine.start's idempotency skips those and only the genuinely-missing ones start.

Putting it together

The four options compose — a schedule that skips pile-up, spreads load, and catches up after downtime:

{
  key: 'hourly-rollup',
  workflow: 'hourly-rollup',
  everyMs: 60 * 60 * 1000,
  overlap: 'skip', // don't pile up if a rollup runs long
  jitter: 10_000, // spread dispatch across instances ticking the same hour
  backfill: { maxCatchup: 6 }, // catch up on up to 6 hours missed while the poller was down
}

Exactly-once across instances

The guarantee that makes scheduling safe in a multi-instance deployment is the idempotent time-bucket run id. Each window — a fixed-interval index, or a cron fire time — maps to a deterministic run id, and engine.start is idempotent by run id: starting the same id twice is a no-op. So when several worker instances tick at the same moment and all try to start the current window, they all compute the same run id and only the first actually creates the run. The poller fires schedules on worker instances only, so a dashboard-only / dispatch-only instance never starts them.

The mechanism is the same one used everywhere durability needs idempotency — see Workflows & steps for how a deterministic run id underpins exactly-once starts in general. The result for scheduling: each window starts exactly once, no matter how many instances are racing the tick or how often the poller fires.

Driving schedules without the NestJS module

The NestJS module's TimerPoller calls the underlying runSchedules(engine, schedules, nowMs, opts?) for you, once on boot and then on timerPollMs (default 1s). If you're using the engine directly — or driving schedules from your own poller, cron process, or serverless trigger — call it yourself:

import { runSchedules } from '@dudousxd/nestjs-durable-core';

await runSchedules(engine, schedules, Date.now());

It fires each schedule's current window (applying paused/overlap/jitter/backfill) and returns the run ids it started. opts lets you override the random/sleep used for jitter — the engine uses real Math.random/setTimeout by default; tests inject deterministic ones instead.

Two lower-level helpers back the run-id math, exported in case you need to look up or debug a schedule's run directly:

  • scheduledRunId(key, everyMs, nowMs) — the deterministic run id for a fixed-interval schedule's current window (sched:<key>:<bucket>).
  • prevCronFireMs(expr, nowMs, timezone?) — the epoch ms of the most recent cron fire at or before nowMs, evaluated in timezone (default UTC) — the same "bucket" a cron schedule's run id is keyed on.

For example, to check whether tonight's daily-report fire has already run:

import { prevCronFireMs } from '@dudousxd/nestjs-durable-core';

const fireMs = prevCronFireMs('0 2 * * *', Date.now(), 'America/Sao_Paulo');
const run = await engine.getRun(`sched:daily-report:${fireMs}`);

On this page