Heximon Logo
Capabilities

Scheduled Work

Run work on a wall-clock cron schedule with ScheduledHandler — a process-local timer on Node, the platform cron trigger on Cloudflare, validated at build time.

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.)

nightly-report.ts
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.
  • name is a stable label for logs and traces; it defaults to the cron string.
  • handle(scheduledAt) always receives the platform's scheduled time, never Date.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.

reports.module.ts
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:

  • NodeNodeScheduleDriver (@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 under pnpm 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() in heximon.config.ts (an edge-class strategy that also declares the Cloudflare vendor). A Nitro cloudflare-* preset selects it for you.
    (The edge runtime class alone is not the vendor — a bare edge build, e.g. a non-Cloudflare *-edge preset, with a scheduled handler but no recognized Cloudflare platform fails with a clear diagnostic instead of emitting the CloudflareScheduleDriver.)
    The cron list is emitted into wrangler.json's triggers.crons (so the platform schedules it), and the platform's scheduled invocation 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/build path (platform: new CloudflareWorkersStrategy()) the Cloudflare Worker host bakes the scheduled handler straight into the emitted worker entry, so vp build produces a self-contained dist/worker.js exporting default { 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 handle to be idempotent — key off scheduledAt (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.run dispatch 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.
Copyright © 2026