Scheduled tasks (in-process cron)
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. It is 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.
- 01scheduleTick firescron expr or intervalMs is due
- 02Single-flight checkalready running? skip + count getState().skipped
- 03Run handler({ signal }) with optional timeoutMs budget
- 04Completedruns++, lastDurationMs recorded
- 05Threw / timed outfailures++, onError(err, { timedOut })
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.
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. It does not coordinate across a cluster.
For work that must survive a restart and run once across the cluster, use background jobs. app.cronEnqueue() keeps the scheduler as the clock but turns each tick into an idempotent enqueue, so eight replicas firing the same 0 2 * * * collapse into one job that one worker runs. Keep app.cron() for process-local maintenance (the other replicas have their own memory to sweep). Use cronEnqueue when the tick's work is a global side effect.