Scheduled Work
Run work on a wall-clock schedule — a nightly cleanup, an hourly rollup, a per-tenant reconciliation — without
standing up an external cron service or hand-rolling setInterval glue.
You declare a job as a class, list it in your module, and it fires on its cron: a process-local timer on Node, the platform's cron trigger on Cloudflare. The cron expression is checked at build time, so a typo fails the compile rather than silently never running.
Declare a scheduled job
A job is class X implements ScheduledHandler<cron, options>. The cron expression is the first type
argument — a string literal the compiler reads as the dispatch key. Optional { timezone?, name? } ride the
second.
It declares handle(scheduledAt), and its constructor is its dependency declaration — injected like
any other Heximon provider, no decorators. (extends ScheduledHandler<...> binds identically — use it when
you want your own base-class hierarchy on top of a job.)
import type { ScheduledHandler } from "@heximon/schedule";
import { ReportsRepository } from "./reports.repository";
export class NightlyReport implements ScheduledHandler<"0 3 * * *", { timezone: "UTC"; name: "nightly-report" }> {
public constructor(private readonly reports: ReportsRepository) {}
public handle(scheduledAt: Date): void {
this.reports.record(scheduledAt);
}
}
- The cron must be a string literal — the compiler turns it into the dispatch key (and the cron-trigger
config it emits for Cloudflare) at build time, so a value computed at runtime couldn't be wired statically. A
malformed expression is a build error (
schedule.invalid-cron), caught by the same parser the runtime uses — so a build-green expression is guaranteed to run. timezone(IANA, e.g."Europe/Budapest") is honored by the Node timer, DST-correct; it defaults to UTC. Cloudflare evaluates all crons in UTC, so a non-UTC zone targeting a Cloudflare build raises a build warning.nameis a stable label for logs and traces; it defaults to the cron string.handle(scheduledAt)always receives the platform's scheduled time, neverDate.now()— so a late or replayed run computes against the intended wall-clock instant.
Register under the schedule namespace
List the handler under the module's schedule namespace, in its handlers list — never in providers. That
is how the compiler knows to route it to its cron rather than treat it as a plain injectable. Anything it
depends on stays an ordinary provider.
import { Module } from "@heximon/runtime";
import { NightlyReport } from "./nightly-report";
import { ReportsController } from "./reports.controller";
import { ReportsRepository } from "./reports.repository";
export class ReportsModule extends Module({
providers: [ReportsRepository],
http: {
controllers: [ReportsController],
},
schedule: {
handlers: [NightlyReport],
},
}) {}
That is the entire app surface. List a class under schedule: { handlers } that names no ScheduledHandler
in its heritage and nothing routes to it — it is constructed but never fires.
One job per cron expression. Two handlers binding the same expression is a build error
(schedule.duplicate-cron): which one runs would be order-dependent. An app that wants several jobs at the same
minute gives each its own expression, or writes one handler that fans out to its collaborators.
How it fires per host
The job dispatches through the same Context.run substrate as every other Heximon tier — a fresh frame per
fire (so an ORM handler opens a clean top-level transaction), with the platform waitUntil threaded through.
The driver owns dispatch, not the clock; where the clock lives differs per host:
- Node —
NodeScheduleDriver(@heximon/schedule/node) owns the clock: it starts one process-local timer per schedule at boot (self-starting — no host wiring) and stops them on shutdown. This is the tier that runs underpnpm dev, a Node server, or any long-lived process. Overlap is skip-if-running: a fire that arrives while the previous one is still running is skipped, so a slow nightly job never stacks. - Cloudflare Workers — the platform's cron trigger is the clock. Selecting this tier means deploying to
Cloudflare: set
platform: new CloudflareWorkersStrategy()inheximon.config.ts(an edge-class strategy that also declares the Cloudflare vendor). A Nitrocloudflare-*preset selects it for you.
(Theedgeruntime class alone is not the vendor — a bare edge build, e.g. a non-Cloudflare*-edgepreset, with a scheduled handler but no recognized Cloudflare platform fails with a clear diagnostic instead of emitting theCloudflareScheduleDriver.)
The cron list is emitted intowrangler.json'striggers.crons(so the platform schedules it), and the platform'sscheduledinvocation is dispatched to your handler.
Both deploy paths wire this for you with no app code: on the Nitro Cloudflare deploy path Nitro merges the crons into its wrangler config and forwards the fired cron to the handler.
On the host-independent@heximon/buildpath (platform: new CloudflareWorkersStrategy()) the Cloudflare Worker host bakes thescheduledhandler straight into the emitted worker entry, sovp buildproduces a self-containeddist/worker.jsexportingdefault { fetch, scheduled }— no hand-written Worker entry.
Semantics
- At-least-once / missed runs are delegated to the platform trigger, not reconstructed. Cloudflare guarantees at-least-once delivery and may fire late; the Node timer fires only while the process is alive (a process down across the scheduled minute misses that run — the Node tier is best-effort).
- Idempotency. Because a run can fire twice (a platform replay, a late fire), and scheduled dispatch flows
through the same substrate as every other tier, write
handleto be idempotent — key offscheduledAt(the intended instant), which is stable across replays of the same tick. - Node multi-replica. N Node replicas each run their own timer, so a cron fires N times across the fleet. Use an idempotency key, designate a single replica, or use the Cloudflare-cron tier. Distributed leader election is out of scope.
See also
- Queue — the sibling tier for moving work off the request path; scheduled
work and queue handlers share the same
Context.rundispatch substrate. - Deploy targets — where each host (Nitro, a Cloudflare Workers entry) boots the schedule driver and wires the cron trigger.
- Scheduled work on Netlify — a nightly report job firing on its cron, observable over an HTTP route, with an e2e that drives the dispatch directly.
Queue
Move slow work off the request path onto durable channels — the Queue producer port, QueueEventHandler consumers under queue { handlers }, per-handler retry and payload validation, MemoryQueue in dev, and a durable transport per platform.
Caching
Multi-tier caching — TieredCache over the Storage port, the Cache facade with single-flight and tag invalidation, QueryCacheInterceptor for CQRS query results, CacheMiddleware for HTTP responses, CacheEnvelope TTL, CdnPurgeAdapter.