Scheduled jobs
Run background work on a schedule — digests, cleanups, syncs. You write named job handlers in api/cron.js, and schedule them with the cron MCP tools. Each job runs with the same env bindings as your routes.
Define handlers in api/cron.js
api/cron.js is a special file (not an HTTP route). Default-export a map of job name → handler. Handlers receive (env, ctx):
// api/cron.js
export default {
async daily_digest(env, ctx) {
const { results } = await env.DB
.prepare("SELECT email FROM subscribers WHERE digest = 1")
.all();
for (const row of results) {
await env.EMAIL.send({
to: row.email,
subject: "Your daily digest",
html: "<p>Here's what's new…</p>",
});
}
},
async cleanup_temp(env, ctx) {
// …
},
};An array of { name, handler } objects works too — use whichever reads better.
Schedule jobs with the MCP tools
manage_cron(action: set) — create or update a schedule, pointing at a job name fromapi/cron.js.manage_cron(action: list) — see an app's scheduled jobs.manage_cron(action: run) — trigger a job immediately (handy for testing).manage_cron(action: delete) — stop a schedule. (The handler stays inapi/cron.jsuntil you remove it.)
In practice you just ask your assistant: “run daily_digest every morning at 7am” — it writes the handler and calls manage_cron (action: set).
Scale a job with shards
A single run has a 5-minute ceiling, which caps how much one job can do per tick. To do more per tick — e.g. poll thousands of tenants every few minutes — fan a job out into shards: set shards (1–12) and the platform invokes your handler that many times each firing, in parallel, each with a ctx.shard = { index, total }. Partition your own work by index so the shards don't overlap:
async poll_tenants(env, ctx) {
const { index, total } = ctx.shard; // e.g. { index: 0, total: 6 }
const { results } = await env.DB
.prepare("SELECT * FROM tenants WHERE rowid % ? = ?")
.bind(total, index)
.all();
// each shard handles its own slice — 6 shards ≈ 6× the work per tick
}Without shards a job runs once per firing ({ index: 0, total: 1 }). Six shards on an every-5-minute schedule cover a full pass roughly every 5 minutes instead of every 30.
skipIfRunning: true to skip a firing for any shard whose previous run hasn't finished — so two runs of the same shard never overlap and race shared rows or an external API's rate limit. Recommended for polling jobs.shards and skipIfRunning every time you update the schedule — omitting them resets to the defaults (1 shard, overlap allowed).Limits
The number of jobs per app, and how often they can run, depend on your plan. Free apps run once a day at most; paid plans can run as often as every 5 minutes — see Limits & quotas.
Each run has a 5-minute ceiling — if a handler runs longer it's cut off and recorded as a timeout (you'll get a heads-up notification at ~4 minutes). For longer work, return quickly and finish in ctx.waitUntil(...), or split it across runs.
manage_cron (action: run) to trigger a job on demand before relying on the schedule — note this is a real run with real side effects (it hits production), not a dry run.