Schedules
Cron and interval schedules that persist in the store: they survive restarts, respect timezones, and let you choose what happens to missed ticks.
Basics#
// Cron string (5-field, optional seconds field supported):
app.schedule("daily-report", "0 9 * * *", async (tick) => {
// tick: { schedule: "daily-report", firedAt: number }
await buildReport();
});
// Interval:
app.schedule("heartbeat", { every: "30s" }, async () => ping());
// Full options:
app.schedule(
"eu-digest",
{
cron: "0 8 * * MON-FRI",
timezone: "Europe/Berlin",
overlap: "skip",
catchup: "runOnce",
jitter: "30s",
},
async () => sendDigest(),
);Schedules are validated at app.start() — a bad cron expression or unknown timezone fails fast with a clear error, not at 3am when the tick was due.
Cron & intervals#
- Cron: standard 5-field expressions with an optional leading seconds field (
*/10 * * * * *= every 10s). Parsed by the croner engine in Rust. - Timezones: any IANA name —
"Asia/Kolkata","Europe/Berlin". Cron evaluation happens in that zone, DST handled by the timezone database. - Intervals:
{ every: "5m" }— next fire = previous fire + interval.
Options#
| Option | Type | Default | Description |
|---|---|---|---|
| timezone | string | UTC | IANA timezone for cron evaluation. |
| overlap | "skip" | "allow" | "queue" | "skip" | What to do when the previous tick is still running. |
| catchup | "skip" | "runOnce" | "all" | "skip" | Missed-while-down policy, applied at startup. |
| jitter | Duration | 0 | Random 0..=jitter delivery delay per fire — spreads thundering herds across a fleet of schedules. |
Overlap policy#
| Policy | Behavior |
|---|---|
skip | Don't fire while a previous tick is still pending or running. For idempotent sync-style jobs. |
queue | Always fire, but execute ticks one at a time in order. |
allow | Always fire; ticks may run concurrently. |
Catch-up policy#
The next fire time is persisted. When a process starts and finds it in the past, the catch-up policy decides what happens:
// Deploy timeline for catchup policies, schedule = every 1h:
//
// 10:00 fires normally
// 10:30 process stops (deploy, crash, laptop lid…)
// 13:45 process starts again — 3 ticks were missed (11:00, 12:00, 13:00)
//
// catchup: "skip" → nothing fires; next tick 14:00
// catchup: "runOnce" → fires ONCE at 13:45, then 14:00, 15:00, …
// catchup: "all" → fires 3 times at 13:45 (capped at 100,
// cap is logged, never silent), then 14:00, …catchup: 'all' is capped
A schedule that was down for a month would otherwise flood the queue. Replay is capped at 100 fires; hitting the cap logs a warning with the number of dropped ticks — never silent.
How it works#
- Definitions are persisted with a canonical spec string. On restart with the same spec, the stored next-fire time wins (continuity); a changed spec recomputes from now.
- A fire enqueues a job onto a hidden queue (
zenzip.schedule.<name>) — one engine, so schedule execution inherits leases, crash recovery, and graceful drain from the queue engine. - The overlap policy maps to that queue's concurrency (
skip/queue→ 1,allow→ 8) plus an active-count guard forskip. - The scheduler loop ticks in Rust (default 250ms, configurable via
schedulerTick) — zero JS wakeups between fires.