Menu

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).

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:

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.

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).

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.