Scheduling
runAfter, runAt, and cron jobs.
Think of @helipod/scheduler as cron built into your database, not bolted on beside it. A
scheduled job is a row in a jobs table. Running it later, once, at an exact time, or on a
recurring cadence, works the same as any other write in helipod: durably, with nothing polling in
the background.
A background driver dispatches the work. It wakes reactively on every commit, plus a wall-clock timer armed to the earliest pending job, so nothing is lost to a crash between claiming a job and finishing it.
It's the foundation two other components build on. @helipod/workflow
dispatches every step through the scheduler's own job queue, and both @helipod/triggers and
@helipod/notifications reuse the same recurring-driver seam for their own background loops.
Enabling it
import { defineConfig } from "@helipod/component";
import { defineScheduler } from "@helipod/scheduler";
export default defineConfig({ components: [defineScheduler()] });defineScheduler() takes no required configuration, and it declares no dependency on any other
component. It's the base layer. Once composed, ctx.scheduler is available in every mutation, and
in every action too, via a delegating action-mode facade, so a function scheduling work is portable
between the two. More on that in Scheduling from an action.
Composing it adds three namespaced tables to your deployment: scheduler/jobs, scheduler/job_args,
and scheduler/crons. You can browse them like any other table in the
dashboard's data browser.
jobs and job_args are split on purpose. jobs is the small, hot row the driver scans every pass
(id, state, nextTs, attempt count). job_args holds the arguments and context payload, which can
be large, so scanning for due work never has to page through it.
Scheduling work
export const notifyLater = mutation({
handler: async (ctx, { userId }) => {
const jobId = await ctx.scheduler.runAfter(60_000, "reminders:_send", { userId }); // in 60s
// or: await ctx.scheduler.runAt(new Date("2026-08-01T09:00:00Z"), "reminders:_send", { userId });
return jobId;
},
});ctx.scheduler.runAfter(delayMs, fnRef, args)schedulesfnRefto rundelayMsmilliseconds from now. It returns the new job's id (astring).ctx.scheduler.runAt(ts, fnRef, args)schedules it for an absolute time.tsis either anumber(epoch milliseconds) or aDate.ctx.scheduler.cancel(id)cancels a still-pendingjob by id. See Canceling and cascading cancel.
Both runAfter and runAt insert the job row in the calling mutation's own transaction. The
schedule commits, or rolls back, atomically with the rest of your write. In a mutation, delay is
clamped to non-negative (Math.max(0, delayMs)), so runAfter(-100, ...) just means "as soon as
possible," not an error. (The action-mode facade skips the clamp and computes an absolute time
instead, so a negative delay there resolves to a moment already past, which also dispatches as soon
as possible.)
fnRef is a bare function-path string ("reminders:_send") or a codegen'd api/internal
reference. Both resolve to the same string path. The target can be a mutation or an action: the
scheduler resolves which at enqueue time (via the runtime's function registry) and dispatches
accordingly. This matters because the two have different delivery guarantees, covered in
Delivery guarantees.
The lower-level enqueue method
runAfter/runAt are the common case, and they only accept a delay or a timestamp. The facade also
carries a lower-level enqueue(fnRef, args, opts) method for the rest of the options. The code
marks it internal: it exists as the primitive component authors build on (it's how
@helipod/workflow schedules every step), and its option surface is narrower than the type
suggests.
runAt wins over runAfter
You can delay an enqueue call either way: an absolute runAt timestamp or a relative runAfter
delay. If you pass both, runAt wins. A negative runAfter clamps to "as soon as possible", the
same as the top-level ctx.scheduler.runAfter method.
await ctx.scheduler.enqueue(
"billing:_charge",
{ invoiceId },
{
runAt: Date.now() + 5 * 60_000,
idempotencyKey: `charge:${invoiceId}`,
onComplete: "billing:_onChargeResult",
context: { invoiceId },
retry: { maxFailures: 8 },
name: "charge-invoice",
},
);EnqueueOpts:
Prop
Type
This is also the primitive other components build on. @helipod/workflow schedules every step
through this same enqueue path, using onComplete/context to learn when a step finished and
what it returned.
Scheduled targets: mutations vs. actions
A job's target can be either kind, and the scheduler resolves which it actually is (via the runtime's function registry) rather than trusting the caller. The two have different delivery guarantees, because only mutations are safely retryable:
- Mutations are deterministic and transactional, so a retry after a crash just re-runs the same write. That's safe by construction. Scheduled mutations get retried on failure (with backoff, see Retries and backoff) and on an infra kill (see Delivery guarantees).
- Actions have arbitrary external side effects (an HTTP call, a charge), so re-running one that
may have already partially executed risks doing it twice. Scheduled actions are at most once by
default: whether the process died mid-flight (an infra kill) or the action's own code threw after
doing external work, the job dead-letters (
state: "failed") instead of being re-dispatched.
Retries for an action are opt-in. Passing retry: { maxFailures } on the lower-level enqueue
path (which is how @helipod/workflow threads a step's declared maxAttempts through) is your
statement that the target is safe to re-run, and a cleanly-failed action then retries with backoff
exactly like a mutation. An infra kill still dead-letters even with the opt-in, because an expired
lease can't tell the scheduler whether the side effects already ran. Make any action you opt into
retries for idempotent.
Job states
Every job row moves through one of five states, tracked as JobState:
| State | Meaning |
|---|---|
pending | Waiting for nextTs to arrive; eligible for the driver to claim. |
inProgress | Claimed by the driver and currently running, with a lease. |
success | Ran and returned normally. Terminal. |
failed | Dead-lettered: either it exhausted its retries, hit a non-retryable error, or (for an action) crashed mid-flight. Terminal. |
canceled | Canceled before it ran, either directly or via cascade. Terminal. |
Canceling and cascading cancel
await ctx.scheduler.cancel(jobId);cancel(id):
- If the job is still
pending, it transitions tocanceledand fires itsonComplete(if any) with{ kind: "canceled" }. - It cascades: walks the job's descendants via a
by_parentindex (an iterative breadth-first walk, not recursion, so a deep chain can't blow the stack) and cancels any of them stillpendingtoo, firing each canceled descendant's ownonComplete, if set. - This walk runs regardless of whether the job itself was cancelable in step 1. Canceling doesn't
preempt a job already
inProgress, but its not-yet-dispatched children still shouldn't run just because their parent already moved on.
A job scheduled while its parent is already canceled is born canceled: it never gets a chance to
run at all. One honest note on scope, though: nothing in production sets parentId today. The
schema field, the by_parent cascade walk, and the born-canceled check all exist and work, but
@helipod/workflow cancels its step jobs directly via scheduler.cancel rather than chaining
them, and a plain ctx.scheduler.runAfter call made from inside another job's own handler is a
top-level job, not a child. The mechanism is wired for a future slice that threads a real "current
job id" through the driver; until then, the cascade only reaches jobs whose parentId was set
explicitly.
The onComplete/context round-trip
await ctx.scheduler.enqueue("reports:_build", { reportId }, {
onComplete: "reports:_onBuildComplete",
context: { reportId, requestedBy: userId },
});
export const _onBuildComplete = mutation({
handler: async (ctx, { jobId, context, result }) => {
// result: { kind: "success", value } | { kind: "failed", error } | { kind: "canceled" }
// context: { reportId, requestedBy } - round-tripped verbatim
},
});If a job's onComplete is set, the scheduler enqueues that mutation (immediately, as soon as the
current commit lands) with { jobId, context, result } on every terminal transition:
success: the job returned normally;result.valueis its return value.failed: the job was dead-lettered (retries exhausted, a non-retryable error, or an action that crashed mid-flight);result.erroris the stringified error.canceled: the job (or an ancestor) was canceled before it ran.
onComplete doesn't fire on the back-to-pending retry transition. The job isn't actually done yet.
context is never interpreted by the scheduler. It's stored alongside the job's args and handed
back byte-for-byte. This pair, a callback plus an opaque payload, is exactly what
@helipod/workflow is built on: every step dispatch is a scheduled
job whose onComplete advances the workflow's own journal.
Recurring jobs: cronJobs()
An app's crons.ts declares recurring work:
import { cronJobs } from "./_generated/server";
const crons = cronJobs();
crons.interval("cleanup", { minutes: 5 }, internal.maintenance.purge, {});
crons.cron("nightly", "0 3 * * *", internal.reports.build, {}, { tz: "America/New_York" });
crons.daily("digest", { hourUTC: 8, minuteUTC: 0 }, internal.email.digest, {});
crons.hourly("heartbeat", { minuteUTC: 0 }, internal.health.ping, {});
crons.weekly("payout", { dayOfWeek: "friday", hourUTC: 17, minuteUTC: 0 }, internal.billing.payout, {});
crons.monthly("invoice", { day: 1, hourUTC: 6, minuteUTC: 0 }, internal.billing.invoice, {});
export default crons;import { defineConfig } from "@helipod/component";
import { defineScheduler } from "@helipod/scheduler";
import crons from "./helipod/crons";
export default defineConfig({ components: [defineScheduler({ crons })] });cronJobs() itself just collects entries in memory. Nothing is scheduled until it's passed to
defineScheduler({ crons }), whose boot step reconciles the registry into the crons table
(matched by name). That reconciliation is idempotent and diff-aware: adding, removing, or editing
a cron between restarts converges to the new schedule on the next boot without double-firing, and
an unchanged registry restarting doesn't restart the cadence's phase.
The six registration methods:
Prop
Type
.cron()'s opts is { tz?: string; catchUp?: CatchUpPolicy }. The four UTC-anchored convenience
methods (.daily/.hourly/.weekly/.monthly) only accept { catchUp? }. They don't take a
tz, since it'd be ambiguous which of their named fields it applies to: their hour/minute/day
fields are UTC by construction. A cron name must be unique within one crons.ts: registering the
same name twice throws at registration time.
Clock-anchored, not now()-anchored
Every fire is computed from the cron's own last-fired timestamp, never from whatever instant the
driver happens to run at. A late dispatch (a busy driver, a slow prior tick) never shifts the phase
of later occurrences. A "0 3 * * *" cron that actually fires at 03:04 one day still computes its
next occurrence as the following day's 03:00, not 03:04 plus the interval. This is why restarting a
deployment with an unchanged schedule never double-fires or drifts.
Catch-up policy
If a deployment is down when one or more occurrences would have fired, catchUp (per-cron, default
"skip") decides what happens to the backlog on restart:
| Policy | Behavior |
|---|---|
"skip" (default) | Discard the missed backlog entirely; resume on the normal cadence. |
"fireOnce" | Fire exactly one job, for the single most recent missed occurrence. |
"fireAll" | Fire one job per missed occurrence, oldest first, hard-capped at 1000 (CATCHUP_CAP) per tick. Anything beyond the cap is discarded and logged, never deferred to a later tick. |
Every policy re-anchors the cadence past the entire true backlog on the same tick, regardless of how
much of it was actually fired, so a cron's future phase never drifts. Only the discarded or skipped
occurrences are lost for good. Each fired occurrence is enqueued with
idempotencyKey: "<cronName>:<fireTs>", so even if the same occurrence were ever computed twice, it
collapses into one work job rather than running twice.
For "fireAll" on an interval spec, the occurrence count is computed in O(1) arithmetic, never by
stepping through the backlog one at a time, so even a fast interval down for months materializes its
capped backlog cheaply. For a cron-expression spec, "skip" and "fireOnce" are each answered by a
bounded handful of cron-parser calls (at most four), regardless of backlog length. Only
"fireAll"'s materialization loop actually steps through occurrences, and that loop is what the
1000 cap bounds.
Retries and backoff
A failed job is retried with jittered exponential backoff, up to retry.maxFailures, then
dead-lettered to state: "failed". The default ceiling is 4 for mutations and 1 for actions, so a
cleanly-failed action dead-letters on its first failure unless you opt into retries explicitly (see
Scheduled targets). A non-retryable, deterministic error
(for example a schema-violating write) skips straight to dead-lettering instead of burning through
every remaining attempt.
The jitter's randomness comes from the mutation's own seeded PRNG (ctx.random), not raw
Math.random(). That's what makes a retry's delay itself replay-deterministic under the engine's
OCC-conflict replay model, and what makes tests computable rather than flaky.
The formula (computeBackoff):
raw = initialBackoffMs * base ** (attempts + 1)
delay = raw * (0.5 to 1.0 jitter)with defaults initialBackoffMs: 250, base: 2 (DEFAULT_BACKOFF_OPTIONS). attempts is the
failure count after this failure is recorded, so with the defaults:
Failure # (attempts) | Raw backoff | Jittered range |
|---|---|---|
| 1 | 1,000ms | 500ms to 1,000ms |
| 2 | 2,000ms | 1,000ms to 2,000ms |
| 3 | 4,000ms | 2,000ms to 4,000ms |
| 4 | n/a | dead-lettered (attempts >= maxFailures), no further retry |
Delivery guarantees
Normal (non-crash) failures behave exactly as described above for both kinds. The distinction
between mutations and actions matters specifically for infra kills: the process dies while a job is
inProgress, between being claimed and finishing.
- A claim holds a lease (
LEASE_MS, 30 seconds). A background sweep (see The driver below) reclaims any job stillinProgresspast its lease deadline. - Mutation jobs are reclaimed back to
pendingwithnextTs: now()(immediate, no backoff, since an infra kill isn't the job's own fault) and their attempt count incremented. Because a mutation is deterministic and transactional, replaying it is safe. The reclaim spends the samemaxFailuresbudget the normal failure path does, so a mutation that reliably crashes the whole process (rather than throwing, which the normal path would catch) dead-letters once the budget is spent instead of being re-dispatched forever. - Action jobs are reclaimed straight to
state: "failed"instead, even if they opted into retries. An expired lease means there's no way to know whether the action's side effects already ran, so retrying could double-run them.onComplete, if set, fires with{ kind: "failed" }, so a caller relying on the callback (like a workflow step) still observes the terminal outcome rather than hanging forever.
Put another way: scheduled mutations are effectively at-least-once at the granularity of the whole
job (bounded by maxFailures), since a crash-recovered retry always eventually lands the same
deterministic write. Scheduled actions are at most once unless you explicitly opt into retries, and
at most once for the crash case no matter what.
The driver
The scheduler's dispatch loop (schedulerDriver) has exactly one periodic timer. Everything else is
event-driven:
- Reactive wake: subscribes to the runtime's commit fan-out (
DriverContext.onCommit) and re-runs its dispatch pass whenever a commit touches anyscheduler/*table (an enqueue, cancel, or completion). A freshly-enqueued due job is picked up with effectively zero latency: no polling interval to wait out. - Wake timer: after every pass, re-arms a single wall-clock timer to the earliest still-pending
job's
nextTs, so a job scheduled for the future still fires exactly when its time arrives, without scanning in between. - Lease-reclaim sweep: a genuinely periodic timer (
SWEEP_MS, 30 seconds by default) that's the only polling in the whole driver, and exists purely as the infra-kill backstop described above. The driver declares it as a backstop (via the driver seam'sbackstopMs), so a host where every wake costs a cold start can stretch the cadence.
Each dispatch pass pulls up to BATCH_CAP (64) due jobs at a time via a snapshot-read query, then
claims and runs each one. Claiming is the authoritative double-run guard: a claim only succeeds if
the job is still observed exactly pending at claim time, and the single-writer transactor
serializes concurrent claims, so even if two wake sources somehow raced, at most one ever
successfully claims a given job. A job that throws is caught per-job, never allowed to escape the
loop and wedge the whole batch. A wake that lands mid-pass (say, an app mutation enqueuing a due-now
job between two awaits) is coalesced into one more pass before the loop exits, rather than silently
stranding the new job until some unrelated future wake.
Scheduling from an action
ctx.scheduler works the same way from an action as from a mutation: runAfter, runAt, and
cancel, with identical signatures. Code that schedules work doesn't need to know which kind of
function it's running in.
The mechanism differs because an action has no ctx.db. Instead of writing a jobs row directly,
each method delegates to an internal mutation (scheduler:_enqueue / scheduler:_cancel) via
ctx.runMutation, which runs the identical enqueue/cancel logic inside its own fresh top-level
transaction. The action-mode facade doesn't expose the general enqueue(fnRef, args, opts) method,
only runAfter/runAt/cancel, so idempotencyKey/onComplete/context scheduling from inside
an action means calling a mutation that does the enqueue call instead.
Cloudflare Containers: scheduled functions do not fire
Scheduled functions do not fire on Cloudflare Containers
If you're deploying to Cloudflare via the Containers path, scheduled functions and crons do not fire. The container stops shortly after its last request, and nothing wakes it back up at an arbitrary future timestamp. A cron set for 03:00 only actually runs if traffic happens to arrive around 03:00.
The Durable-Object-native Cloudflare host doesn't have this gap. The DO's own alarm wakes scheduled
work through idle and hibernation, so ctx.scheduler/cronJobs() behave the same as on any other
deployment target. If your app depends on this component, prefer the DO-native path, or a deployment
target that keeps a process running continuously (self-hosting, the
standalone binary).
Related
- Workflows: durable multi-step orchestration built on this same job
dispatch (
defineWorkflow,requires: ["scheduler"]). - Triggers and Notifications: two other components with their own recurring drivers on the same seam.
- Components overview: what a component can contribute in general, and how composition works.