Scheduled functions
Run a function on a cron schedule, with no caller: when to use it, the expression syntax, what a run can do, and how to check that it ran and finished.
Add schedule to an async function and it runs by itself, with no caller: nightly cleanups, daily digests, hourly syncs, monthly reports.
examples/config/workshop/main/_functions/nightly_cleanup/function.yaml
# A scheduled function: `schedule` (cron, UTC) on an async function. There is
# no caller, so ctx.input is empty, ctx.user is null, and ctx.admin.db (full
# access, recorded as this function) is always available in a scheduled run.
# Needs at least one running worker: `backd worker`, or
# `backd serve --with-worker`.
#
# `internal: true`: nobody can call it over HTTP. An administrator can still
# run it by hand to test it, as its schedule would run it:
# backd functions invoke --function workshop/main/nightly_cleanup
internal: true
mode: async
schedule: "0 3 * * *" # every day at 03:00 UTC
timeout: 10mexamples/config/workshop/main/_functions/nightly_cleanup/index.ts
// nightly_cleanup: delete draft orders nobody finished within 30 days.
//
// It only ever deletes drafts that are already past the cutoff, so running
// it twice (a lost worker's job is picked up again) is harmless.
import type { Context } from "../lib/types.ts";
const DAYS = 30;
export default async function handler(ctx: Context) {
const orders = ctx.admin.db("main").collection("orders");
const cutoff = new Date(Date.now() - DAYS * 86_400_000).toISOString();
let deleted = 0;
for (;;) {
const page = await orders.list({ where: { status: "draft", "_meta.created_at": { $lt: cutoff } }, limit: 100 });
if (page.items.length === 0) break;
await ctx.admin.db("main").batch(page.items.map((o) => ({ op: "delete" as const, collection: "orders", id: o.id })));
deleted += page.items.length;
}
console.log(`deleted ${deleted} draft orders older than ${DAYS} days`);
return { deleted, cutoff };
}Each run is an ordinary async job: created on schedule instead of by a request, run by a worker, stored, recorded in the history, and read back like any job. Everything on the jobs page applies, including at-least-once delivery.
When to use cron
Use a scheduled function when the work must happen because time passed, whether or not anybody asks: expiring old data, aggregating a day’s activity, syncing with another system, sending a reminder.
| Instead of cron, use… | When |
|---|---|
| An async job started by the client | A person or app action starts the work (an export button) |
A sync function | The caller needs the answer now |
| A webhook | Another service tells you when something happened |
| An external scheduler (system cron, CI, a cloud scheduler) calling a function with an API key | You need per-timezone schedules, more than minute resolution, or a schedule that changes without redeploying |
Schedule expressions
Five fields, minute hour day-of-month month day-of-week, in UTC unless the function sets a timezone:
| Field | Values | Also |
|---|---|---|
| minute | 0–59 | |
| hour | 0–23 | |
| day of month | 1–31 | |
| month | 1–12 | jan–dec |
| day of week | 0–6 (Sunday is 0, and 7) | sun–sat |
Each field takes *, a number, a range (9-17), a list (1,15) or a step (*/15, 9-17/2). When both day fields are restricted a time matches if either does, as in classic cron. @hourly, @daily, @weekly, @monthly and @yearly are shortcuts. A wrong expression stops backd at startup and names the file.
| Schedule | Runs |
|---|---|
*/15 * * * * | every 15 minutes |
@hourly (0 * * * *) | at the start of every hour |
0 3 * * * | every day at 03:00 UTC |
@daily (0 0 * * *) | every day at 00:00 UTC |
30 6 * * mon-fri | weekdays at 06:30 UTC |
0 9 1 * * | on the 1st of the month at 09:00 UTC |
0 0 * * 0 | Sundays at 00:00 UTC |
There are no seconds: the smallest step is a minute.
Time zones
A schedule is read in UTC by default. Add timezone, an IANA name such as Europe/Madrid or America/New_York, and the same fields mean wall-clock time there:
mode: async
schedule: "0 9 * * mon-fri" # 09:00 in Madrid, in winter and in summer
timezone: Europe/Madrid
timezone needs schedule. An unknown name stops backd at startup and names the file; Local is not accepted. The zone database is built into backd, so it works in any image. Everything outside the schedule stays UTC: the job id, created_at and every timestamp in the job list and the history.
When the clocks change, the usual cron rules apply:
| The clocks… | A schedule with a fixed hour (30 2 * * *) | A schedule that matches every hour (*/15 * * * *, @hourly) |
|---|---|---|
| go forward (a local hour does not exist) | A time inside the gap runs once, right after it: 30 2 * * * runs at 03:00 that day | Runs on, the gap simply has no runs |
| go back (a local hour happens twice) | Runs once, at the first occurrence | Runs through both hours |
So a daily job in a zone with daylight saving runs exactly once a day, and an hourly one every real hour. A schedule at 0 12 * * * is unaffected twice a year except that its UTC time moves by an hour.
Another example: a daily digest
daily_digest writes one document per UTC day. It runs at 00:00 and summarizes the day before; an API key can also call it by hand for any day, which is how you backfill after an outage:
examples/config/workshop/main/_functions/daily_digest/function.yaml
# Runs once a day, at 00:00 UTC (@daily), and summarizes the day before.
#
# It can also be called by hand to (re)build one day, for example after an
# outage: an API key calls it with {"day": "2026-09-28"}. A scheduled run has
# no caller and always gets ctx.admin; a call made by hand only does with
# `admin: true`, so it is set here.
mode: async
schedule: "@daily"
admin: trueexamples/config/workshop/main/_functions/daily_digest/index.ts
// daily_digest: one `digests` document per UTC day with that day's order
// count and paid revenue. A scheduled run summarizes yesterday; a call made
// by hand can name any day ({"day": "2026-09-28"}). The day is the key (a
// unique index backs it), so running it again for the same day writes
// nothing new.
//
// To send the digest by email instead, keep the aggregation and replace the
// last create with a fetch to your mail provider's API (declare its host
// under `network:` and its key under `secrets:`; see the docs, Functions ->
// Secrets and Outbound network).
import type { Context } from "../lib/types.ts";
export default async function handler(ctx: Context) {
const db = ctx.admin.db("main");
// A scheduled run has no input: default to yesterday, in UTC.
const requested = (ctx.input as { day?: string } | null)?.day;
const today = new Date();
today.setUTCHours(0, 0, 0, 0);
const day = requested ?? new Date(today.getTime() - 86_400_000).toISOString().slice(0, 10);
const start = new Date(`${day}T00:00:00Z`);
const end = new Date(start.getTime() + 86_400_000);
const digests = db.collection("digests");
if ((await digests.list({ where: { day }, limit: 1 })).items.length > 0) {
return { day, skipped: true };
}
let orders = 0;
let revenue = 0;
const created = { "_meta.created_at": { $gte: start.toISOString(), $lt: end.toISOString() } };
for await (const order of db.collection("orders").iterate({ where: created })) {
orders++;
if (order.status === "paid") revenue += order.amount as number;
}
const digest = await digests.create({ day, orders, revenue });
return { day, orders, revenue, digest_id: digest.id, skipped: false };
}# Rebuild the digest of one day by hand (an API key; the run acts as the function)
curl -X POST https://api.example.com/v1/workshop/main/_func/daily_digest \
-H "Authorization: Bearer $API_KEY" -H 'Content-Type: application/json' -d '{"day": "2026-09-28"}'
What a scheduled run is
- It acts as the function. There is no caller:
ctx.inputis empty (null),ctx.userisnull,ctx.dbacts as an anonymous caller (which, subject to the rules, can do little), andctx.admin.dbis always available, with full access recorded asfunc:<realm>/<database>/<name>, whether or notadmin: trueis set. Review a scheduled function as if it declaredadmin: true. (A call made by hand needsadmin: trueto getctx.admin, which is whydaily_digestsets it.) - It is an async function.
mode: async, sotimeoutruns up to 24 hours (15 minutes by default) and the async limits apply.scheduleneedsauth: enabled. - Secrets and network work as usual: declare what it needs.
- A failed run can be retried:
retryworks as for any async job. The run keeps its id; later minutes don’t start a second one. - It can still be called by hand through its
invokerule, like any async function; that run has a caller and its own input. Administrators can also run it by hand as its schedule would run it.
Skipping a run while the previous one is going
Add overlap: skip to function.yaml and a scheduled time that arrives while the function still has a job that hasn’t finished (queued, waiting for a retry, or running) doesn’t run:
mode: async
schedule: "*/5 * * * *"
overlap: skip
The skipped time is not lost from view: it appears in the job list as a job that is already done, with the result skipped and a message naming the run that was still going. No worker claims it, so there is no history record and no output. backd functions jobs --scheduled and the Jobs page show it like any other run, and it is kept for the usual retention.
- The guard looks at every job of the function, not only its scheduled ones: a run started by hand or re-run while one is going also makes the next scheduled time skip.
- A skipped time is not made up later. The next time runs normally once nothing is unfinished; there is no backlog.
- Without
overlap(or withoverlap: allow, the default) every scheduled time runs, even when the previous run is still going. - It is checked when the run is created, by whichever worker gets there first, so with several workers it is a guard against overlap in the ordinary case, not a lock: a run can still start between the check and a manual call.
Pausing a schedule
An administrator with the functions area can stop a schedule for a while and start it again, with no change to function.yaml and no redeploy:
backd functions pause --function workshop/main/nightly_cleanup
backd functions schedules --realm workshop # which schedules exist, and which are paused
backd functions resume --function workshop/main/nightly_cleanup
The same is available as POST /_admin/functions/{database}/{name}/pause and /resume, GET /_admin/schedules, client.admin.schedules.pause('main/nightly_cleanup'), .resume() and .list(), and as a Pause button on the Functions page of the admin UI, which also marks a paused schedule. Pausing and resuming are audited (schedule.pause, schedule.resume).
- While paused, no run is created. Runs that are already queued or running are not touched (cancel them if you want them gone), and the function can still be called or run by hand.
- A resume doesn’t make up what was missed. The next scheduled time after the resume runs; a time that fell inside the pause never does, even if the pause ended seconds later.
- The state is stored in the realm’s database, not in an instance: it survives restarts and redeploys and applies to every instance and worker at once, within a worker’s 15-second check. Changing the schedule in
function.yamlkeeps it paused. - Pausing a paused schedule (or resuming a running one) changes nothing and isn’t audited twice. Only a function with a
schedulecan be paused: any other answers409. - It adds a system collection (
schedules) to the realm’s system database: runbackd provision(or start withPROVISION_MODE=apply) after upgrading, as for any new system collection.
A worker must be running
# compose.yaml: a dedicated worker, next to the API (env as in the production reference)
worker:
image: ghcr.io/fernandezvara/backd:v0.8.1
command: ["worker"]
environment:
CONFIG_DIR: /config
MONGO_URI: mongodb://mongo:27017/?replicaSet=rs0
BACKD_EXECUTOR_URL: http://executor:9100
BACKD_EXECUTOR_TOKEN: ${BACKD_EXECUTOR_TOKEN}
BACKD_CALLBACK_URL: http://backd:8081
BACKD_CALLBACK_KEY: ${BACKD_CALLBACK_KEY}
BACKD_SECRETS_KEY: ${BACKD_SECRETS_KEY} # only if a function declares secrets
How runs are created
- Exactly one run per scheduled time, across any number of workers, with no leader. Every worker checks the schedules every 15 seconds and tries to create the job of a due run. The job’s id comes from the function and the scheduled minute,
cron_<database>_<function>_<yyyymmddhhmm>(UTC), and the database accepts only one job with that id: one worker wins, the rest find it exists. Adding or losing workers needs no configuration. - Missed runs are skipped, not replayed. A run is created only within 5 minutes after its scheduled time. If every worker was down for longer, that run doesn’t happen. A run that was created but whose worker died is picked up by another when its lease expires, like any job.
- Changing a schedule means editing
function.yaml, rebuilding and redeploying (the config fingerprint changes, soPROVISION_MODE=verifyinstances need the new config). To stop a schedule for good, remove theschedulekey the same way; to stop it for a while, pause it, with no redeploy.
Checking that a run happened and finished
Every run is a job with a predictable id, so you can look at any of them:
# Recent runs of one function, newest first: state, tries and outcome
backd functions jobs --function workshop/main/nightly_cleanup --scheduled --since 7d
# CREATED FUNCTION STATUS RESULT CODE DURATION TRIES NEXT ATTEMPT SCHEDULED ID
# 2026-09-30T03:00:05.512Z main/nightly_cleanup done ok - 412ms 1 - true cron_main_nightly_cleanup_202609300300
# One run in full (output included), with an API key, from the id above
curl https://api.example.com/v1/workshop/main/_jobs/cron_main_nightly_cleanup_202609300300 -H "Authorization: Bearer $API_KEY"
# → {"status": "done", "result": {"status": "ok", "output": {"deleted": 3, "cutoff": "2026-08-31T03:00:05.210Z"}, "duration_ms": 412}, …}
# What it printed
backd functions logs --function workshop/main/nightly_cleanup --since 24h
# 2026-09-30T03:00:05.900Z cron [log] deleted 3 draft orders older than 30 days
- A run that should have happened is missing when no worker was up within five minutes of its scheduled time. Look for a gap in
backd functions jobs --scheduled, and check the workers’ logs. - A run is
queued/runningfor too long: same causes as any job that doesn’t finish. - A run failed:
result.statusisn’tok, and the history and logs say why. - The same list is available to programs:
GET /v1/{realm}/_admin/jobs?function=main/nightly_cleanup&scheduled=trueorclient.admin.jobs.list({ function: 'main/nightly_cleanup', scheduled: true }).
backd has no built-in alerting. To be told when a schedule breaks, run a small check from wherever you already monitor things (an API key with the admin role) and alert when the newest run of a function is older than expected or isn’t ok:
const { items } = await backd.admin.jobs.list({ function: 'main/nightly_cleanup', scheduled: true, limit: 1 })
const last = items[0]
const stale = !last || Date.now() - Date.parse(last.created_at) > 26 * 3600_000
const failed = last?.status === 'done' && last.result.status !== 'ok'
if (stale || failed) alert(`nightly_cleanup: ${stale ? 'no recent run' : last.result.code}`)
Testing a schedule
- Run the function by hand first:
backd functions invoke --function workshop/main/nightly_cleanup.nightly_cleanupis internal, so it has no HTTP route andinvokeruns it through the admin API, as its schedule would run it (withctx.admin; it answers with a job to look at, as any async call does). That exercises the code without waiting for the clock. - Use a fast schedule locally:
schedule: "* * * * *"in the local stack (itsbackdruns a worker) shows a run every minute; put the real schedule back before you commit. - Unit-test the logic: cron changes nothing about how the function’s code is tested (Build, dev and test).