Jobs
@Cron
Runs a method on a cron schedule. Five fields give minute precision, six fields add seconds, and nicknames such as @hourly, @daily or @weekly work too.
@Cron('*/15 * * * *', { name: 'reports.refresh' })
public async refresh(): Promise<void> {}
@Cron('*/10 * * * * *', { name: 'metrics.flush', scope: 'instance' })
public flush(): void {}
@Cron('@daily', { name: 'sessions.prune', timezone: 'America/New_York' })
public async prune(): Promise<void> {}
An invalid expression or an unknown time zone throws a SchedulerError when the class is bound, naming the job and the expression, so a typo fails the boot rather than the first tick.
@Every
Runs a method at a fixed interval, written in milliseconds or as a duration such as '30s', '5m', '1h', '1d' or '1h30m'. The shortest interval is one second.
@Every('5m', { name: 'feeds.poll' })
public async poll(): Promise<void> {}
Occurrences are aligned to the Unix epoch: a 5 minute job runs at :00, :05, :10 and so on, not five minutes after the process started. That is what lets every replica agree on which occurrence is due, whenever it booted. Shift the grid with offset:
// 00:15, 01:15, 02:15...
@Every('1h', { name: 'reports.hourly', offset: '15m' })
public async hourly(): Promise<void> {}
Options
Every option can also be set for all jobs at once through the plugin's defaults. An option set on the job wins.
| Option | Default | Description |
|---|---|---|
name | Class.method | Stable id of the job. Part of every lock key, so it must not change between deploys. Required for cluster jobs. |
scope | 'cluster' | cluster runs an occurrence on exactly one replica, instance on every replica. |
timezone | 'UTC' | IANA time zone a cron expression is read in. @Cron() only. |
offset | 0 | Shift of the epoch-aligned grid. @Every() only. |
overlap | 'skip' | What happens when the previous run is still going. skip drops the new occurrence, across the whole cluster for a cluster job. allow runs both. |
misfire | 'skip' | What happens to an occurrence that was missed. run-once runs the latest missed one, never a backlog. See Misfires. |
misfireGrace | '1m' | How late a timer may fire and still count as on time. |
timeout | none | Hard limit per run. Aborts ctx.signal with reason timeout and records the run as failed. |
lockTtl | '30s' | Lifetime of the overlap lock of a cluster run. A heartbeat extends it every third of it. |
jitter | 0 | Random delay, up to this long, before claiming an occurrence. Spreads load without changing which occurrence it is. |
retries | 0 | How many times a failed run is repeated right away, within the same occurrence and lock. |
target | 'inline' | Where the work runs. { queue: 'reports' } enqueues it on @vercube/queue instead. See Queue targets. |
external | false | Only external triggers fire the job, never an in-process timer. |
paused | false | Registered, but not scheduled until resume(). |
The job context
A job method receives one argument, its context. The return value is ignored, and throwing marks the run as failed.
@Cron('0 * * * *', { name: 'exports.hourly', timeout: '10m' })
public async export(ctx: SchedulerTypes.JobContext): Promise<void> {
ctx.logger?.info(`exporting ${ctx.occurrenceId}, attempt ${ctx.attempt}`);
for await (const page of this.gExports.pages({ until: ctx.scheduledAt })) {
ctx.signal.throwIfAborted();
await this.gWarehouse.write(page, { idempotencyKey: ctx.occurrenceId });
}
}
| Field | Description |
|---|---|
name | Name of the job. |
occurrenceId | name@ISO instant, identical on every replica. Use it as an idempotency key. |
scheduledAt | When the occurrence was scheduled. Identical on every replica. |
startedAt | When this replica started it. |
trigger | timer, misfire, manual, external or queue. |
attempt | Starts at 1, grows with every retry. |
signal | Aborted on timeout, on a lost lock or on shutdown. signal.reason is a SchedulerError whose operation says which. |
instanceId | Id of the replica running it. |
logger | A logger carrying the job and occurrence, when @vercube/logger is bound. |
A promise cannot be killed, so an aborted handler that ignores its signal keeps running in the background. The scheduler gives it one second to settle, then records the run and releases its locks. Check ctx.signal between steps of long work.
Jobs added at runtime
Jobs declared in code cover most needs. For jobs that depend on data, such as one sync per tenant, call add() on the manager. They live in the process that added them.
for (const tenant of await tenants.all()) {
scheduler.add({
name: `tenant.${tenant.id}.sync`,
schedule: { every: '15m' },
handler: (ctx) => sync(tenant, ctx.signal),
});
}
remove(name) takes a job away again, and pause(name) / resume(name) stop and restart its timer on this replica only.
Time zones and daylight saving time
Every occurrence is computed as a UTC instant, so replicas in different time zones agree on it. A cron expression is read in the job's timezone, which matters twice a year:
- Spring forward. A fixed time that does not exist runs once, when the gap ends. In
Europe/Warsaw,30 2 * * *runs at 03:00 local time on 29 March 2026. - Fall back. A fixed time that happens twice runs once, on its first pass.
30 2 * * *runs at the first 02:30 on 25 October 2026. - Interval jobs follow the wall clock. A job with a wildcard, step, range or list in its seconds, minutes or hours (
*/30 * * * *,0 1,2 * * *) skips the times spring forward removes and runs on both passes through the hour fall back repeats. InEurope/Warsaw,*/30 * * * *runs at 02:00 and 02:30 twice on 25 October 2026.
This is how Vixie cron behaves.
For high-frequency jobs, use timezone: 'UTC' or @Every(), which ignore the wall clock entirely.