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 from api/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 in api/cron.js until 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.

Overlap protection. Set 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.
Updating a job re-parses it whole, so pass 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.

Jobs run against your production bindings. Use 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.