---
title: 'Scheduled jobs'
slug: scheduled-jobs
ogTitle: 'Durable scheduled jobs with Mochi.cron()'
description: 'Run recurring work on a cron schedule with Mochi.cron(), backed by a durable, multi-node scheduler.'
---
## Scheduled jobs
Run recurring work — nightly cleanups, hourly syncs, a weekly digest — on a cron schedule. `Mochi.cron()` declares a job; `Mochi.serve({ cron })` starts it. Jobs are **durable** and **run once across a multi-node setup**: the schedule is persisted and a single node is elected per firing, so scaling to N nodes does not fire a job N times.
```ts
// file: src/index.ts
import { Mochi } from 'mochi-framework';
import { routes } from './routes';
const cleanup = Mochi.cron('cleanup', '0 3 * * *', async () => {
await purgeExpiredSessions();
});
await Mochi.serve({ cron: [cleanup], routes });
```
The third argument is the handler — a bare function as above, or `{ run, … }` when you need [options](#options). The descriptor is inert until `Mochi.serve()` starts it, so declaring one at module scope is free.
### Storage
`cronStorage` sets where schedules and their jobs live:
```ts
await Mochi.serve({
queueStorage: { postgres: process.env.DATABASE_URL },
cronStorage: { sqlite: '.db/cron.sqlite' }, // cron on its own store
cron: [cleanup],
});
```
- Accepts `memory`, `{ sqlite }`, `{ postgres }`, or `{ pglite }`.
- **Defaults to `memory`**, independent of `queueStorage`. Cron always runs on its own instance under its own `mochi_cron` namespace — a Postgres schema, or a table-name prefix on SQLite — so queues and cron never share tables even when pointed at the same store.
Pointing both at the **same SQLite file** is safe table-wise but puts two writers on one file. Give cron its own file when both are durable.
`memory` (and any per-node store) coordinates the run-once guarantee only **within one process**. For a multi-node deployment, point `cronStorage` at shared storage — Postgres, or a SQLite file on a shared volume.
### Schedules
Standard 5-field cron syntax — `minute hour day-of-month month day-of-week` — plus the nicknames `@yearly`, `@monthly`, `@weekly`, `@daily`, `@hourly`. Month and weekday accept names (`MON-FRI`, `JAN`). Resolution is **one minute** — the smallest interval is `* * * * *`.
```ts
Mochi.cron('every-15-min', '*/15 * * * *', run);
Mochi.cron('weekdays-at-9', '0 9 * * MON-FRI', run);
Mochi.cron('nightly', '@daily', run);
```
An invalid expression throws **at declaration**, not at boot, so a typo fails when the module is imported rather than after a deploy.
### Options
Instead of a bare handler, pass `{ run, … }`:
- `tz` — IANA time-zone name the schedule is read in. Defaults to **UTC** — durable cron reads one zone across every node.
- `dev` — set `false` to skip the job when `development: true`. Default `true`.
```ts
Mochi.cron('digest', '0 9 * * MON', {
tz: 'Europe/Stockholm',
dev: false,
run: async () => sendWeeklyDigest(),
});
```
### Runs once, transactionally, across a multi-node setup
Each firing is claimed by exactly one node through an atomic database update, and the enqueue is deduplicated by a per-minute key. You do **not** need to hand-roll an idempotency key — the scheduler handles the race, and it corrects for clock skew against database time. This is the reason cron is durable rather than a per-node timer.
### A run is a queue job
A scheduled run executes internally as a [queue](/docs/queues/) job named `cron-`, so its lifecycle surfaces through the queue events — `queue:active`, `queue:completed`, `queue:failed` with `queue: "cron-"`. Registration emits one `cron:scheduled` event.
A handler that throws is reported through `queue:failed` and logged; the schedule stays registered and runs again at its next occurrence.
The handler receives the run: `{ name, schedule, scheduledTime, tz? }`. `scheduledTime` is the epoch ms at which the scheduler claimed the firing — the same value on every retry of that firing, so it works as an idempotency key.
```ts
Mochi.cron('sync', '*/15 * * * *', async ({ name, scheduledTime }) => {
await syncOnce(`${name}:${scheduledTime}`);
});
```
The `cron-` prefix is reserved: a `Mochi.queue()` name may not start with it, so cron jobs and queues never collide.
### Editing and removing jobs
Schedules persist in the database. On each boot Mochi reconciles: it registers the declared jobs and **removes any schedule it manages that is no longer declared**, so deleting a `Mochi.cron()` line cleans up its schedule instead of leaving an orphan that keeps enqueuing jobs no worker consumes. In development, editing the `cron` array re-registers on save — no dev-server restart needed.
### Shutdown
The scheduler stops on `SIGTERM`/`SIGINT`, on `server.stop()`, and on [`Mochi.stop()`](/docs/queues/#mochistop). Schedules are **not** removed on shutdown — they are durable and resume on the next boot.