Basic Self-Hosted Schedule Runner
The Basic Self-Hosted Schedule Runner scans the configured Runtime Schedule store from a long-lived process and executes due Runtime Schedules in-process.
Use it when the application owns a single server process or has a clearly elected single runner. It is not a distributed scheduler.
Minimal Runtime Schedule example
First define a runtime-eligible target.
import { defineSchedule } from '@vitehub/schedule'
export default defineSchedule({
allowRuntimeSchedules: true,
cron: '0 9 * * *',
handler: async (context) => {
console.log('run daily report', context.scheduleId, context.scheduledAt.toISOString())
},
})
Then create a Runtime Schedule that targets that definition.
import { schedules } from '@vitehub/schedule'
await schedules.create({
cron: '0 9 * * *',
id: 'daily-report',
target: 'reports/daily',
})
Start from a Node entry point
Start the runner once from the process that should own dispatch.
import { startScheduleRunner } from '@vitehub/schedule'
const runner = startScheduleRunner({
concurrency: 1,
onError(error) {
console.error('Schedule runner error', error)
},
})
process.once('SIGINT', () => runner.stop())
process.once('SIGTERM', () => runner.stop())
startScheduleRunner() returns a controller with running and stop().
Start from a Nitro server plugin
For Nitro, start the runner from one server plugin and stop it during shutdown.
import { startScheduleRunner } from '@vitehub/schedule'
export default defineNitroPlugin((nitroApp) => {
const runner = startScheduleRunner({
concurrency: 1,
onError(error) {
console.error('Schedule runner error', error)
},
})
nitroApp.hooks.hookOnce('close', () => {
runner.stop()
})
})
Only install this plugin in the deployment process that should run schedules.
Start from another server entry point
Any long-lived server entry can start the runner before listening for requests.
import { createServer } from 'node:http'
import { startScheduleRunner } from '@vitehub/schedule'
const runner = startScheduleRunner()
const server = createServer((_request, response) => {
response.end('ok')
})
server.listen(3000)
process.once('SIGTERM', () => {
runner.stop()
server.close()
})
Matching behavior
The runner checks enabled Runtime Schedules on an interval. The default interval is one minute.
For each scan, it floors the current time to the current UTC minute and matches Runtime Schedule cron expressions against that minute. It does not backfill missed minutes after process downtime, slow scans, deploys, or clock jumps.
Concurrency and failures
concurrency bounds how many schedule executions this runner instance dispatches at once. The default is 1.
When a handler fails, the run and attempt are marked failed, the error is passed to onError when provided, and the runner keeps scanning future minutes. The current runner does not retry failed attempts.
Before dispatching, the runner checks whether a run record already exists for the Runtime Schedule id and scheduled minute. That prevents duplicate dispatch from the same store when one active runner owns the store.
Single active runner per store
Run only one active runner against a given Runtime Schedule store and Schedule Run store.
The current runner does not provide distributed claiming or leases. If two server processes scan the same store at the same time, both can decide a schedule is due before either has written a run record.
Use one elected process, one singleton service, or a deployment topology that guarantees only one active runner for each store. For anything else, treat this runner as a development or simple self-hosted primitive and keep distributed scheduling outside this package for now.
For the explicit scope limits, read Boundaries.

