Scheduled and background jobs
A site normally runs code only while a visitor's request is open. Jobs let the platform run a script by itself: on a schedule (every night, every 15 minutes, weekdays at 08:30) or on demand ("Run now" in the admin UI). Typical uses are cleaning expired rows, sending a digest, refreshing data from an API, or working through a backlog.
A job is an ordinary script with the same engines, the same SDK and the same grants as the rest of your site. What differs is who starts it, and the limits it runs under.
Status. Schedules, manual runs, retries, run history, pause and resume, and the admin pages are implemented. A job also starts from site code with
ctx.jobs.enqueue(see Starting a job from code), on native, Wasm and the Linux worker.
A first job
Declare it in the manifest and put the script under jobs/:
{
"schemaVersion": 1,
"permissionsRequested": { "database": true },
"jobs": {
"cleanup": { "script": "jobs/cleanup.js", "schedule": "0 3 * * *" }
}
}
// jobs/cleanup.js
export default async function (request, ctx) {
await ctx.db.execute("DELETE FROM sessions WHERE expires_at < unixepoch()");
return new Response("cleaned"); // any 2xx answer means success
}
Deploy, and ask a platform administrator to grant the jobs capability to the website (Jobs page, Grant jobs). Without the grant the jobs are listed but never run, exactly like other capabilities that were requested but not granted. A deploy is never blocked by a missing grant.
The manifest block
jobs is an object. Each key is the job name: lowercase letters, digits, - and _, at most 64 characters, starting with a letter or digit. runs and policy are reserved. At most 32 jobs per manifest; the platform allows fewer per website (10 by default), and further jobs are listed as blocked.
| Field | Meaning |
|---|---|
script |
Required. A .js file under jobs/, without hidden (.) segments. Scripts under jobs/ can never be a route, an error page or a static file, so no visitor can reach them. |
schedule |
Optional. Five cron fields or an alias (below). Absent: the job runs only on demand. |
timezone |
Optional IANA name such as Europe/Amsterdam. Default UTC. The schedule is read in this zone. |
overlap |
skip (default) or queue. What happens when a tick comes while the previous run is still running or waiting. skip records the tick as skipped. queue keeps one waiting run. |
catchUp |
none (default) or latest. What happens to ticks missed while the platform was not running. none records one "missed" entry and runs nothing. latest runs once for the most recent missed tick. A long outage never causes a burst of runs. |
timeoutMs |
Optional run limit, 1,000 to 3,600,000. It can only lower the platform maximum (10 minutes by default). Default 30 seconds. |
retries |
Extra attempts after a failure, 0 to 5. Default 0. |
The same block works in microhost.config.js: defineSite({ jobs: { ... } }).
Cron syntax
minute hour day-of-month month day-of-week, for example */15 * * * * (every 15 minutes) or 30 8 * * MON-FRI (weekdays at 08:30).
- Fields accept
*, single values, lists (1,15), ranges (1-5), steps (*/5,10-30/5,5/20), and names (JAN..DEC,SUN..SAT, any case). Day-of-week7is Sunday. - Aliases:
@hourly,@daily(also@midnight),@weekly(Sunday 00:00),@monthly(day 1, 00:00). - Not supported: seconds,
L,#,W,?, and ranges that wrap (22-2; write22-23,0-2). - When both day-of-month and day-of-week are restricted, a day matches if either matches (
0 12 13 * 5runs on every 13th and on every Friday). This is how classic cron behaves. - A schedule that can never fire (
0 0 31 2 *) is refused when you deploy. - The platform enforces a minimum interval between ticks (5 minutes by default). A more frequent schedule is stored but blocked, and the Jobs page says so.
Daylight saving
Times are read in timezone. A local time that does not exist (the hour skipped when clocks go forward) runs at the same offset after the gap: 30 2 * * * becomes 03:30 that day. A local time that happens twice (when clocks go back) runs once, at its first occurrence. A tick never runs twice.
What your script receives
A job is called like a request, so all engines and the SDK work unchanged:
- Method
POST, path/_jobs/<name>, and the JSON payload ({}when there is none) as the request body. - Headers describing the run. Read them from
request.headers:
| Header | Value |
|---|---|
x-microhost-job-run |
The run id. Stays the same across the retries of one run. |
x-microhost-job |
The job name. |
x-microhost-job-attempt |
The attempt, starting at 1. |
x-microhost-job-trigger |
schedule or manual (the trigger of the run, also on a retry; x-microhost-job-attempt tells a retry apart). |
x-microhost-job-scheduled-for |
The cron tick as an RFC 3339 UTC time. Absent for manual runs. |
The platform sets these headers and removes every x-microhost-* header from public requests, and /_jobs/... is refused to visitors. Inside jobs/ you may trust them. Never use them to authorize anything in your api/ handlers.
- Success is a response with status 200 to 299. Any other status, an exception, a timeout, a memory error or a cancellation is a failure. The first 4 KiB of the response body is kept as the run's output, so return a short summary such as
Deleted 40 rows. - A job has the site's grants (the manifest intersected with the live policy), revoked at the next host call like any other execution. It has no visitor: no cookies, no session.
Delivery: at least once
Your job can run more than once for the same tick or run: after a crash or restart while it was running, and after a failed attempt that is retried. Write jobs so a repeat is harmless: delete by condition, INSERT ... ON CONFLICT, or record the run id (x-microhost-job-run) of work already done.
Failed attempts are retried when retries allows it, with a growing wait: 1 minute, 5 minutes, 30 minutes, 2 hours, then 6 hours. Timeouts, server errors (5xx), script errors and engine errors are retried; a refused capability, a memory error, a 4xx answer, a removed job and an invalid response are not. Manual runs have one attempt.
A job that fails 10 runs in a row is suspended: it stops being scheduled until a person resumes it on the Jobs page. A success resets the count.
Starting a job from code
ctx.jobs.enqueue(name: string, payload?: unknown, options?: { delaySeconds?: number; key?: string }): Promise<{ runId: number }>
ctx.jobs exists when the site declares at least one job and an administrator granted it jobs. enqueue writes a durable run of the named job (trigger enqueue) and resolves with its run id; the run starts when the scheduler next picks it up, after delaySeconds (0 to 7 days, default 0).
payloadis any JSON value, at most 64 KiB. The job reads it withawait request.json()(an empty body when there is none).keyis an idempotency key of 1 to 128 bytes. While a run with the same key is queued or running,enqueuereturns that run and creates no other, so a retried request does not queue the work twice.- The run uses the job's declared
retriesand the job's own limits, not the caller's. - Errors: an unknown job name or an invalid argument rejects with
INVALID_INPUT; a revoked grant or a job blocked by platform policy withCAPABILITY_DENIED; more queued runs than allowed (100 per site and 20 per job by default) withQUOTA_EXCEEDED; a paused site withHOST_UNAVAILABLE. - Revoking the grant stops enqueues at once, including from executions that are already running.
- A job may enqueue jobs, including itself.
A job is not a daemon
Every run is a bounded execution (default 30 seconds, at most 10 minutes). There are no long-lived worker processes, no sockets and no setInterval. For a big backlog, use the worker pattern: a request or a job stores the work (rows in your database) and calls ctx.jobs.enqueue("process-batch", undefined, { key: "batch" }); the job processes up to N rows, and while work remains it enqueues itself again with a small delaySeconds. Use a key so a burst of requests queues one run, not hundreds. A schedule with a cursor in your database is the alternative.
Limits
Jobs run under their own limits, separate from a visitor's request:
| Limit | Request | Job |
|---|---|---|
| Wall clock | 1 s | 30 s default, 10 min maximum (timeoutMs) |
| Memory | 32 MiB | 64 MiB |
Host calls (ctx.db, fetch, ...) |
64 | 1,000 |
| Database rows | 100,000 | 1,000,000 |
| Outbound bytes | 8 MiB | 32 MiB |
Capabilities, egress allowlists and the mail allowance of the site apply unchanged: a job can send mail only if the site can, and it counts against the same hourly allowance.
The numbers above are the platform defaults. A platform administrator can set different values for one website (Job limits on the Jobs page): the default and longest run time, the memory and host calls of a run, how many of its jobs run at once, and how many runs may be queued for the website and per job. Each value is bounded by the platform maximum, so an override can never exceed what the host allows.
Jobs also share a small platform-wide capacity pool (2 at the same time by default) and at most one runs per website at a time (unless an administrator raised that for your website), so a busy job queue cannot starve visitors. A run that waits for capacity starts when a slot frees up; a run that waits more than 24 hours expires.
Run history and the admin pages
Jobs (site menu) lists each job with its schedule in words and as cron, the next run, the last result and its state (Active, Paused, Suspended, Removed). Use Run now to queue one run with an optional JSON payload, and Pause/Resume to stop and restart the schedule. Below it, Runs shows the history with trigger, time, duration, attempt and status. Open a run to see its timings, the release that ran, the output and, for a failure, a plain-language reason. Queued and running runs can be cancelled.
Run history is kept for 30 days and at most the latest 200 runs per job. Platform administrators also get a Jobs page with capacity, the next scheduled runs and recent runs across all websites.
Run now and Cancel need the same permission as deploying; Pause and Resume the same as changing site settings. Everything is recorded in Activity (job.run, job.cancel, job.pause, job.resume).
Run states and error codes
| State | Meaning |
|---|---|
| Queued | Waiting for its start time or for capacity. |
| Running | Executing now. |
| Succeeded | The script answered 2xx. |
| Failed | The last attempt failed (see the code). |
| Skipped | A tick came while the previous run was still active (overlap: "skip"). |
| Missed | Ticks were missed while the platform was not running (catchUp: "none"). |
| Cancelled | A person cancelled it, or the job was removed from the release. |
| Expired | It waited more than 24 hours. |
console.log and the other console.* calls of a run are kept with it: the last 4 KiB, with values you read through ctx.env replaced, shown under "Console" in the run details.
Error codes are fixed words, never text from your code: timeout, memory, script_error, status_<n>, capability_denied, job_removed, job_blocked, interrupted, capacity, engine_error, no_active_release, release_lookup, release_unavailable, script_missing, response_too_large, invalid_response, cancelled, expired. console.log output is not kept with the run, so put what you need to see into the response body (the first 4 KiB is kept as the run's output).
Which release runs
Jobs come from the active release. A run executes the release that is active when the run starts, so a rollback also rolls back job code. A job removed from the manifest stops being scheduled within seconds of the next activation and its queued runs are cancelled; its history stays. A changed schedule takes effect from the next tick.
Examples
Nightly cleanup with a retry
"jobs": { "cleanup": { "script": "jobs/cleanup.js", "schedule": "0 3 * * *", "timezone": "Europe/Amsterdam", "retries": 2 } }
Weekday digest mail
// jobs/digest.js
export default async function (request, ctx) {
const rows = (await ctx.db.query("SELECT email FROM subscribers WHERE digest = 1")).rows;
for (const [email] of rows.slice(0, 8)) {
await ctx.mail.send({ to: email, subject: "Your digest", text: "…" });
}
return new Response(`Queued ${Math.min(rows.length, 8)} digests.`);
}
Mail is limited per execution and per hour, so the script sends a bounded batch per run and the schedule continues.
Backlog in bounded slices
// jobs/import.js (schedule: every 5 minutes, overlap: skip)
export default async function (request, ctx) {
const batch = (await ctx.db.query("SELECT id, url FROM pending ORDER BY id LIMIT 20")).rows;
for (const [id, url] of batch) {
const reply = await fetch(url);
await ctx.db.execute("INSERT INTO pages(id, body) VALUES (?, ?) ON CONFLICT(id) DO UPDATE SET body = excluded.body", [id, await reply.text()]);
await ctx.db.execute("DELETE FROM pending WHERE id = ?", [id]);
}
return new Response(`${batch.length} done`);
}
The upsert and the delete make a repeated run harmless.