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}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 minutesUse 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-aware — 0 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-parserOmitting 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 tickJitter 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 beforenowMs, evaluated intimezone(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}`);Versioning & determinism
Keeping in-flight runs replay-safe across code changes — workflow versions for breaking changes, the NonDeterminismError guard, the deterministic now/random/uuid sources, and ctx.patched for guarding an in-place change without a new version.
Reliability
How nestjs-durable keeps long-running work correct in the face of transient failures, crashes and overload — step retries, saga compensation, durable flow-control queues and the dead-letter queue.