Overview
Deployment-agnostic scheduled jobs in Voltro — one *.cron.tsx definition that runs unchanged on a single box, a multi-instance fleet, or an external scheduler.
A schedule is a job the clock invokes — "send the digest at 09:00", "prune sessions every 15 minutes". In Voltro a schedule lives in a *.cron.tsx file and default-exports defineSchedule({...}).
The defining idea: the job definition is identical across every hosting topology. The cron expression, timezone, and handler never change whether you run one PM2 process, ten Kubernetes replicas, or hand the firing off to AWS EventBridge. What differs — who fires it and how exactly-once is guaranteed — is configuration, not code.
// apps/api/schedules/digest.cron.tsx
import { defineSchedule } from '@voltro/runtime'
export default defineSchedule({
name: 'dailyDigest',
cron: '0 9 * * *', // 09:00, five-field cron
timezone: 'Europe/Berlin', // REQUIRED — never server-local
handler: async ({ app }) => {
const users = await app.store.users.where({ digestOptIn: true }).all()
for (const u of users) await sendDigest(app, u)
},
})That's the whole job. Drop the file in your api app, run voltro dev, and it fires at 09:00 Berlin time.
What's in this section
- Defining a schedule —
defineSchedule, cron syntax, the timezone rule, the handler context - Coordination —
single/advisoryLock/cluster: exactly-once across instances - Trigger drivers —
self(in-app timer) vsexternal(platform scheduler hits an HTTP endpoint) - Overlap & backfill — what happens on slow runs and missed firings
- Deployment — generating Kubernetes / AWS / GCP / Azure manifests with
voltro schedule-manifest - Dashboard — firing history, status, and "Run now"
Schedule vs workflow
Schedules and workflows are both durable execution, but they answer different questions.
| You have… | Use |
|---|---|
| A job the clock starts on a recurring cadence | Schedule |
| A multi-step job that must survive a deploy mid-flight | Workflow |
| "Run this daily, and each run is one quick operation" | Schedule |
| "Run this daily, and each run is a long multi-step saga" | Schedule with a direct workflow target or a handler that starts one |
| A user-facing request returning in <100ms | Mutation |
The wedge: schedules answer when, workflows answer how durably. A schedule handler is "a mutation the clock invokes" — same app context, same store, same plugin interceptors. If the handler's work itself needs to survive crashes, the handler kicks off a workflow and returns.
That handoff works the same in voltro dev and the production API server: when the app has workflow files, schedule handlers receive app.workflows and can start durable work without importing a client.
// apps/api/schedules/daily-rollup.cron.tsx
import { defineSchedule } from '@voltro/runtime'
export default defineSchedule({
name: 'dailyRollup',
cron: '0 2 * * *',
timezone: 'UTC',
handler: async ({ app, scheduledAt }) => {
await app.workflows!.start('billing.dailyRollup', {
day: scheduledAt.toISOString().slice(0, 10),
})
},
})Earlier framework guidance suggested modelling cron with a workflow plus
Effect.sleep. That still works for one-off durable delays, but recurring cadences belong in a*.cron.tsxschedule — you get coordination, backfill, overlap control, and a dashboard for free.
The three things you configure
- The job —
defineSchedule({ cron, timezone, handler }). Lives in the file. Topology-independent. - The trigger — who drives the clock: an in-app timer (
self) or an outside scheduler (external). App-wide default, overridable per job. See trigger drivers. - The coordination — how N instances avoid double-firing:
single,advisoryLock, orcluster. App-wide. See coordination.
// apps/api/app.config.ts
export default {
type: 'api' as const,
name: 'api',
scheduling: {
trigger: 'self', // default; 'external' to delegate the clock
coordination: 'advisoryLock', // default on every multi-instance SQL store; 'single' on memory/sqlite
},
}Sensible defaults derive from your store: memory / sqlite → self / single, every multi-instance SQL store (postgres / mariadb / mysql / mssql) → self / advisoryLock (the claim-row gate works on all of them). Most apps never set this block.
Guarantees and honest caveats
- Deterministic firing instant.
scheduledAtis derived from the cron expression, not each replica'sDate.now(), so every instance computes the same time-bucket — the basis for clock-skew-safe coordination. - Non-dying timer. The
selfdriver re-arms after every firing, even if a handler throws; a handler defect can't break the chain. - Exactly-once is only as strong as your coordination.
singledoes not dedupe — running twosingleinstances double-fires. UseadvisoryLockorclusterfor multi-instance. See coordination. - Sub-minute
clusterfirings lag. Theclusterstrategy waits for shard assignment (~10s on a cold runner) before the first firing. Fine for minute-and-up cadences; not for "every second". - External triggers are minute-granular. Six-field (seconds) cron is rejected when generating external manifests — k8s/EventBridge/Cloud Scheduler can't express sub-minute. See deployment.