---
title: "DurabilitySchedule"
description: "Recurring ticks, overlapping runs, and durable outcome delivery."
---

> Documentation Index
> Fetch the complete documentation index at: https://durability.shirei.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# DurabilitySchedule

Import `DurabilitySchedule` from `durability/experimental` and pin an exact version. It owns one recurring schedule per scheduler, with independently tracked overlapping runs. It is not an operation retry loop.

## How it uses your Durable Object

`DurabilitySchedule` adds no storage or infrastructure of its own. It uses the Durable Object it is constructed in:

- **`ctx.storage.sql`** holds one schedule row and one row per run from its tick until its outcome is delivered, so each object has exactly one schedule. No finished-run history remains.
- **The object's one alarm**, owned by the shared `DurabilityScheduler`, is set to the next tick, and your class's `alarm()` must delegate to the scheduler. While runs or outcome deliveries are in flight, the alarm invocation stays open.
- **The live instance** holds the run slots in use, the deliveries in flight, and that open loop. Eviction discards them; a run left marked running is reported as interrupted, not run again.

Click the diagram to open a full-screen explorer with the complete source files. Select a component to jump to its code, or select a linked code line to highlight the affected architecture. Use the ← / → keys or on-screen arrows to move through the flow; each step's explanation sits under the diagram. Escape or the close button returns to the guide. Failure scenarios, the full step list, and durable/in-memory state notes are available under Details. These are explanatory traces of the real source, not executable simulations or guarantees about an external service.

## Behind the scenes

**Durable state:** `durability_schedule` holds the versioned expression, fixed jitter, and next nominal tick. `durability_schedule_runs` keeps a run from tick creation until outcome acknowledgement or delivery exhaustion.

A due-tick transaction advances the schedule cursor and inserts the run together. A duplicated alarm cannot insert the same tick twice. Missed ticks collapse into one run for the earliest missed nominal time, rather than replaying every missed interval.


**After eviction:** a previously running row with no live owner becomes `ScheduledRunInterruptedError`; its handler is **not** run again. Its external effects may nevertheless have happened. This differs intentionally from [Durability operations](/primitives/operations/).

**Outcome delivery:** persisted outcomes retry at least once up to their configured budget. `onOutcome` may repeat, so deduplicate by run identity. Run concurrency is separate from the scheduler's ordinary permit pool. Cancel stops future ticks, not already due/running work. Once outcome delivery settles or exhausts, no finished-run history remains.

Implementation: [`schedule.ts`](https://github.com/avenceslau/durability/blob/main/packages/durability/src/schedule.ts) (`DurabilitySchedule`, tick, orphan interruption, and outcome delivery paths).

## Recurring schedules

`DurabilitySchedule` is exported from `durability/experimental`. It runs one recurring schedule per Durable Object: each tick starts a run, runs overlap, every run is tracked on its own, and its outcome is reported once and then forgotten. It replaces a `DurabilityAlarms` tick that registers a `Durability` operation per run, which needs retained records to deduplicate and keeps them until purged.

```ts
import { DurableObject } from 'cloudflare:workers';
import { DurabilityScheduler } from 'durability';
import { DurabilitySchedule } from 'durability/experimental';
import { nextCronTime } from './cron';

export class Cron extends DurableObject<Env> {
  private readonly scheduler = new DurabilityScheduler({ context: this.ctx });
  private readonly schedule = new DurabilitySchedule({
    scheduler: this.scheduler,
    // Durability ships no cron parser: return the first tick after `after`.
    nextOccurrence: (expression, after) => nextCronTime(expression, after),
    run: ({ scheduledTime, idempotencyKey, signal }) =>
      this.env.TARGET.fire({ scheduledTime, idempotencyKey }, { signal }),
    onOutcome: (run, outcome) => this.env.TARGET.report(run, outcome),
    maxConcurrentRuns: 20,
    runTimeoutMs: 15 * 60_000,
  });

  configure(expression: string, version: number) {
    // Spread objects sharing an expression over the first 30 seconds.
    return this.schedule.set({
      expression,
      version,
      jitterMs: { maxMs: 30_000 },
    });
  }

  remove(version: number) {
    return this.schedule.cancel(version);
  }

  override alarm(info?: AlarmInvocationInfo) {
    return this.scheduler.alarm(info);
  }
}
```

- **Versions.** `set({ expression, version })` stores the schedule and its next tick only when `version` is higher than the stored one, and resolves `true` when it did. A lower version is ignored. The same version changes nothing but reconciles the physical alarm, so resending it repairs a lost alarm. The expression is opaque; only `nextOccurrence(expression, after)` reads it, and it must return a safe integer after `after`, or `undefined` to stop ticking. `get()` returns `{ expression, version, jitterMs, nextAt }`, where `nextAt` is the next tick's nominal time, or `undefined` when no schedule is live.
- **Jitter.** `set({ expression, version, jitterMs })` delays every tick by one fixed offset, so many objects sharing a popular expression do not all fire at the same instant. `jitterMs` is either a non-negative integer, the offset itself, or `{ maxMs }`, which picks an offset uniformly in `[0, maxMs]` when the version is stored. It defaults to 0, and both forms are at most 2^31 - 1 ms. The offset is stored in the schedule's row, so it costs no write per run, and resending the same version keeps it; only a new version picks a new one. A tick's nominal time `scheduledTime` is what `nextOccurrence` returned, and it fires at `dueAt`, which is `scheduledTime + jitterMs`. `idempotencyKey` derives from `scheduledTime`, and the next tick is always searched from nominal times, so the offset never drifts. Keep `jitterMs` below the expression's interval; durability cannot know the interval of an opaque expression. A larger offset is allowed: every tick still fires once, in nominal order, each `jitterMs` after its nominal time, even when that is after the next tick's nominal time.
- **Cancel.** `cancel(version?)` stops future ticks and resolves `true` when a schedule was live. Runs already due or running still finish and report. With a version, the cancel is ignored when the stored version is higher, and is otherwise kept as a one-row tombstone so a delayed `set` of that version or lower cannot revive the schedule.
- **One tick, one run.** A tick inserts its run in the transaction that advances the stored next tick, so a replayed or duplicated alarm finds the cursor already moved and cannot fire the tick again; deduplication needs no finished-run records. When ticks were missed, for example while the object was down, they collapse into one run for the earliest missed time, and the next tick is the first whose `dueAt` is after now. A `nextOccurrence` that throws or returns an invalid time rolls the tick back and fails the alarm run, which is retried at `alarmMinDelayMs` and reported through `onAlarmRetry`.
- **Runs.** `run({ scheduledTime, dueAt, idempotencyKey, signal })` starts outside the alarm pass that recorded it, so a long run never delays later ticks. `idempotencyKey` is `<objectId>:<scheduledTime>`. At most `maxConcurrentRuns` (default 10) are in flight; later runs wait durably in tick order. This limit is separate from the scheduler's `alarmConcurrency`. A run that throws, or exceeds `runTimeoutMs` (default 15 minutes, which aborts its `signal`), is `errored`. A run is attempted once and never retried. A timed-out run keeps its slot until its handler actually settles.
- **Keeping the object alive.** While runs or outcome deliveries are in flight, the alarm pass stays pending, so an alarm invocation stays open and holds the object in memory; it fires the next ticks and outcome retries itself, at their exact times. Before the wall-time limit the scheduler hands off at `alarmHandoffMs`, and the next alarm joins the same loop. A tick started by an in-memory timer is joined by the next physical alarm. Other helpers on the same scheduler still run on each alarm pass, but work of theirs that becomes due during the hold waits for it unless the scheduler uses `liveTimers`.
- **Interrupted runs.** A run still marked running when no live instance holds it, for example after the object was evicted, reset, or redeployed mid-run, is reported as `errored` with `ScheduledRunInterruptedError` on the next alarm, and is not run again. While a run or outcome delivery is in flight, the physical alarm stays on the next tick, which is its eviction fallback, so an interrupted run is reported by the next tick at the latest, or within `alarmMinDelayMs` when no tick is left.
- **Outcomes.** `onOutcome(run, outcome)` receives `{ scheduledTime, dueAt, idempotencyKey, attempt }` and `{ status: 'complete' }` or `{ status: 'errored', error: { name, message } }`. The outcome is persisted before the first delivery, so delivery is at least once with the same outcome, even across a crash. Returning acknowledges it and deletes the run's row; nothing about the run is kept afterwards. Throwing, or exceeding `outcomeTimeoutMs` (default 60 seconds), retries it with `outcomeRetries.delay(attempt)` (default jittered exponential capped at 5 minutes) up to `outcomeRetries.maxAttempts` (default 10) deliveries. After the last, the row is deleted, `durability.schedule.outcome_dead_lettered` is reported, and a `dead_lettered` lifecycle event is emitted.
- **Storage.** Two rowid tables with no secondary indexes: `durability_schedule` holds the schedule's one row and `durability_schedule_runs` holds one small row per run from its tick until its outcome is delivered. A run writes four rows: the tick's cursor update and run insert, the outcome update, and the delete on delivery. It also arms the physical alarm once, for the next tick, and each `setAlarm()` is billed as a row written. A run that waits for a slot writes one more row, and each failed delivery one more.
- **Lifecycle events.** `onLifecycleEvent` receives `scheduled_run` events keyed by `idempotencyKey`: `due` (with `queued`), `started`, `settled` (with `outcome`, `error`, and `durationMs`), `outcome_retry`, and `dead_lettered`. Like every lifecycle hook, it is best effort.

One schedule attaches per scheduler; attaching a second throws. Construct it once per object instance, as with every helper. `DurabilitySchedule.migrate(this.ctx, null)` removes its schema.

Source: https://durability.shirei.dev/primitives/schedule/index.mdx
