Cron
Cron is an optional layer for scheduled jobs. Drop a file in cron/, and serve() starts it automatically — no cron/ directory means the code is never imported. Each tick runs behind a lock so a job never overlaps itself; on a single instance that needs no infrastructure, and when you scale to several replicas you point the lock at shared storage so a given tick fires exactly once across the fleet.
// cron/sendReminders.ts — a job is two named exports: details + process
export const details = {
name: "sendReminders",
cron: { value: "0 9 * * *" }, // every day at 09:00
};
export const process = async () => {
await sendDueReminders();
};
Sub-minute schedules need the 6-field (seconds-first) cron syntax —
"*/30 * * * * *"is every 30 seconds. The standard 5-field form has a one-minute floor.
How it works
- File-based — one job per file in
cron/, mounted by convention. - Overlap-safe — before running, a job acquires a lock keyed to its name. If it's held, the tick is skipped — no duplicate runs, no external scheduler.
- Opt-in — the layer is off until the
cron/directory exists. The framework imports no scheduler otherwise.
Choosing a lock backend
The lock is chosen by the CRON_LOCK env var. The default (memory) needs no infrastructure — cron just works on a single instance. Pick a shared or persistent backend only when you actually need it.
CRON_LOCK | Scope | Needs | When to use |
|---|---|---|---|
memory (default) | This process only | — | A single instance. Zero config. |
none | No lock | — | You want every tick to fire, always. |
sqlite | One node / shared volume | (optional) CRON_LOCK_SQLITE_PATH | A single instance where the lock should survive a restart. |
postgres | All replicas on the DB | DATABASE_URL | Multiple replicas, you already run Postgres. Uses pg_try_advisory_xact_lock — no table. |
redis | All replicas on the Redis | REDIS_URL | Multiple replicas, you already run Redis. Uses SET NX EX. |
# single instance — nothing to set, this is the default
# CRON_LOCK=memory
# scale out on Postgres
CRON_LOCK=postgres
DATABASE_URL=postgres://user:pass@host:5432/db
# or on Redis
CRON_LOCK=redis
REDIS_URL=redis://host:6379
Per-backend URL overrides exist if the lock store differs from your app's main one: CRON_LOCK_DATABASE_URL, CRON_LOCK_REDIS_URL. sqlite and redis use Bun's built-in drivers, so no backend adds an npm dependency.
Note on
sqlite: the lock lives in one file, so it coordinates only processes that share it — a single node or a shared volume, not separate machines. For true cross-replica locking usepostgresorredis.
Running a job on demand
startCron() returns a controller so you can fire a job outside its schedule — e.g. run a sync right after a user action. To get the handle, turn off serve's auto-wiring and start cron yourself (otherwise the jobs schedule twice):
import { serve } from "@prehoy/baguette";
import { startCron } from "@prehoy/baguette/cron";
serve({ routesDir: "./api", cron: false }); // don't auto-wire
export const cron = await startCron({ dir: "./cron" });
// ...later, from a route:
await cron.trigger("syncReviews"); // runs that job now, respecting the lock
cron.jobs lists the loaded job names.
Scaffold one
baguette new cron sendReminders
This creates cron/sendReminders.ts with the boilerplate in place. See the CLI reference.
Related
- serve — auto-wires
cron/when the directory is present. - Automations — the event-driven sibling of cron.