Scheduled tasks (in-process cron)
As of 0.37.0 DaloyJS ships a queue-agnostic schedule primitive: run periodic work inside this process on a fixed interval or a cron expression. It is the in-process counterpart to an external job queue — reach for it for cache sweeps, token refresh, reconciliation, and other housekeeping, not for distributed fan-out. It has zero runtime dependencies and three properties a production scheduler needs:
- Flexible schedules— fixed intervals (
intervalMs) or 5-field cron expressions (cron, with@hourly/@daily/… aliases and an optional IANAtimeZone). - Single-flight— a task never overlaps itself. If a tick fires while the previous run is still in progress, the tick is skipped (and counted), so a slow task can never pile up unbounded concurrent runs.
- Graceful shutdown —
app.cron()ties the scheduler to the app lifecycle: on shutdown it stops arming new runs, awaits in-flight runs, and aborts theirAbortSignalif they outlast the grace period. Timers areunref'd, so a scheduler never keeps an idle process alive on its own.
Quick start with app.cron()
The easiest entry point is app.cron(). The first call lazily creates an app-managed Scheduler, starts it, and registers the shutdown drain for you.
Inspect or manually trigger tasks through app.scheduledTasks (the underlying Scheduler):
Cron expressions
The cron field accepts a standard 5-field expression (minute hour day-of-month month day-of-week) with wildcards, lists (1,15,30), ranges (1-5), steps (*/5), and case-insensitive month/day names. Day-of-week accepts both 0 and 7 for Sunday. The named aliases @yearly, @monthly, @weekly, @daily, and @hourly are also supported.
Cron expressions evaluate in UTC by default. Pass an IANA timeZone to schedule against a wall clock:
Parsing is purely arithmetic (no backtracking regular expressions), and a malformed or unsatisfiable expression (for example 0 0 30 2 * — the 30th of February) throws a CronParseError at registration time, not silently at runtime.
Single-flight & overruns
Schedules are fixed-rate: the next tick is armed before the current run starts. If a run outlasts its interval, the overlapping tick is skipped rather than started concurrently, and the skip is counted in getState(name).skipped. This guarantees at most one concurrent run per task — a slow task degrades to “runs back-to-back” instead of fanning out.
Per-run timeouts
Set timeoutMs to bound a run. When it elapses the run's signal is aborted; forward it to your I/O so the handler unwinds promptly. A timed-out run is recorded as a failure and reported to onError with timedOut: true.
Using the Scheduler directly
For lifecycles you manage yourself (workers, scripts, tests), construct a Scheduler and drive it directly. It accepts an injectable clock and timer primitives, which makes it fully deterministic under test.
The cron utilities are exported standalone too: parseCron(expr) compiles an expression to its field sets, and nextCronRun(expr, after?, timeZone?) returns the next matching Date.
When to reach for a real queue instead
This scheduler runs in-process: each instance of your app runs its own timers. That is exactly what you want for idempotent maintenance, but for work that must run exactly once across a horizontally-scaled fleet — or that must survive a process restart — use a durable queue or a leader-elected external scheduler and have the elected instance call runNow(). The single-flight guarantee is per-process, not cluster-wide.