Skip to content

Scheduled jobs

Scheduled jobs let your app act later. A job adds a record to a Data collection, sends an email, calls one of your app’s own routes, or any combination — at a future time, once or on a repeat. It survives restarts, idle periods and redeploys in between. Reminders, a follow-up message, “release this at noon”, a nightly roll-up that runs your code — all without a cron service, a queue, or a server that has to stay awake.

Like the other capabilities it’s framed as an outcome, not a raw mechanism: you schedule a result, not a bare timer you then have to wire up.

Jobs live at /api/_schedule, served from your app’s own origin — but the app doesn’t write those fetches. It imports schedule, and the module calls them:

import { schedule, listJobs, getJob, cancelJob, ScheduleError } from "schedule";
const { id, nextRun } = await schedule({
in: 3600,
collection: "reminders",
put: { message: "Stand-up in 15 minutes" },
});

listJobs() answers the array itself, getJob(id) answers the job itself — or null once it has fired or been cancelled, which is the ordinary end of a one-off rather than something to catch. cancelJob(id) answers the boolean. A failed request throws a ScheduleError carrying .status and the server’s own message — 401 sign in first, 429 slow down, 413 that record or email is too big, and .guard === true when the bot check is uncleared, which is the one you await guard() and retry.

Method & path Body Returns
POST /api/_schedule {"in":3600,"every?":86400,"at?":<unix ms or {"hour":9,"zone":"..."}>,"id?":"...","put?":{...},"collection?":"reminders","email?":{"to?":"...","subject":"...","html?":"...","text?":"..."},"call?":"/tasks/...","data?":{...}} 201 {"id":"job_...","nextRun":<unix ms>}
GET /api/_schedule {"jobs":[{"id","collection","put","email","call","nextRun","lastRun","repeat","attempts","lastError","createdAt"}]}
GET /api/_schedule/:id one job (404 if it fired or was cancelled)
DELETE /api/_schedule/:id {"deleted":true}

The difference is who the job belongs to.

A job created through schedule() belongs to one user — whoever was signed in when the app created it. It fires once or on a repeat, and only they can list or cancel it. That is a reminder, a follow-up email, “do X in N minutes”.

A job declared in schedule.json belongs to nobody, so it runs whether or not anyone is using the app. That is the app’s cron — a nightly digest, an hourly roll-up. It is a declaration, never a call; see A job for the whole app below.

A job that writes a record needs the collection declared in data.json like any other — see Data. The collection and every field the job writes are checked when you schedule it, not when it fires:

{
"collections": {
"reminders": { "fields": { "message": "string" } }
}
}

An undeclared collection, or a field the collection never declared, is a 400 that names what is declared — you find out at the call, not silently at 3 a.m. when the alarm goes off. (A collection whose name starts with _ is refused too; those are the platform’s.)

A job carries a put, an email, a call, or any combination. A job with none of them is a 400: it would do nothing.

// add a record an hour from now
await schedule({
collection: "reminders",
put: { message: "Stand-up in 15 minutes" },
in: 3600, // seconds from now — or use `at`: <unix ms> (or a Date) for an exact time
});
// → { id: "job_…", nextRun: 1750000000000 }
// email every morning, no record at all — and so no collection to name
await schedule({
every: 86400,
id: "daily-nudge",
email: { subject: "Today's list", text: "Open the board." },
});

collection belongs to put. Name it when the job writes a record; leave it out when the job only sends mail. Sending a collection with no put is a 400 — it would name a collection the job never touches.

Field Meaning
put optional — the record to add, stamped with _scheduled and _firedAt; validated against its collection now, not at fire time
collection the Data collection put writes to — declared in data.json; required with a put, refused without one
email optional — an email sent when the job fires; needs Email on
call optional — a route of your own under /tasks/ to POST when the job fires
data optional — a JSON object handed to that route; only meaningful with a call
in / at fire after in seconds, or at the at unix-ms time — a Date works too, the module reads its time (one is required). An at already in the past fires immediately
every optional — repeat every N seconds after firing (minimum 60; 0 means no repeat)
at as an object a clock time rather than an instant — see At a clock time. Repeats forever, and takes no in and no every
id optional — a stable id (truncated at 128 chars); reusing one reschedules that job instead of adding another

in and every measure elapsed seconds, which is the wrong unit for anything a person reads on a schedule. “Every 86400 seconds” starts drifting from the hour you meant the moment you redeploy, and it moves by an hour when the clocks change. So at also accepts a clock:

// 8:30 every morning, in the user's own time zone
await schedule({
id: "morning-list",
at: { hour: 8, minute: 30, zone: "Asia/Kolkata" },
email: { subject: "Your morning list", text: "Open the board." },
});
// 5pm every Friday
await schedule({ id: "weekly", at: { day: "fri", hour: 17 }, call: "/tasks/weekly" });
Key Meaning
hour required — 0–23, in zone
minute optional — 0–59, defaults to 0
zone optional — an IANA time zone like America/New_York; defaults to UTC
day optional — sun mon tue wed thu fri sat; without one the job runs every day
date optional — a day of the month, 1–31; a month too short clamps to its last day, so 31 fires on Feb 28

A clock job repeats forever at that local time — there is no separate every to add, and sending one is a 400. day and date are mutually exclusive.

Daylight saving is handled: a job set for 9am keeps firing at 9am local all year, even though the UTC instant it lands on shifts by an hour twice a year. The zone is the one the person lives in, so store it on their account and pass it here rather than assuming UTC.

put and email cover a lot, but not everything: recomputing a leaderboard, calling a connected API, fanning out a digest, tidying up old rows. For those, a job calls a route your app serves.

Write the route in the app’s own server file, under /tasks/:

server.mjs
app.post("/tasks/rollup", async (c) => {
const { from, at, user, data } = await c.req.json();
const res = await c.env.PLATFORM.fetch("/api/_data/orders?limit=100");
const { items } = await res.json();
// …your code.
return c.json({ ok: true });
});

server.mjs is a lone module with no module graph, so it can’t import schedule, data or anything else. Server code reaches a capability through env.PLATFORM.fetch instead, not a bare fetch: your server runs inside the app’s worker, so a relative fetch("/api/_data/…") has no origin to resolve and throws. PLATFORM.fetch takes the same paths the module calls, returns a normal Response, and carries the caller’s session — so a route acting for a signed-in person sees exactly what that person is allowed to see. It reaches /api/_… paths only. That is the one place a raw call to /api/_schedule is right; from the browser, it never is.

and name it in the job — from the browser, through the module:

await schedule({ every: 3600, id: "rollup", call: "/tasks/rollup" });
// or once, with a payload
await schedule({ in: 900, call: "/tasks/remind", data: { orderId: "o_1" } });

When the job fires, the platform POSTs that path with the envelope every background run gets — the same shape for a queued message and a flow step, so one route can serve all three:

Field What it is
from { kind, name }kind is schedule, queue or flow; name is the job’s id here
at when it fired, unix ms
user the id of the user the job belongs to — null for a declared job
data whatever you passed when scheduling

The payload is data, not the body. A route that reads the whole body as its payload gets the wrapper instead.

Answer 2xx and the job is done. Answer anything else — or throw — and the run counts as failed, which means it is retried with the same widening backoff as any other job. So make the route idempotent: it can run twice for the same fire. A job carrying both a call and an email runs the call first, so a route that keeps failing never mails anyone twice.

The route runs as the user the job belongs to: env.PLATFORM.fetch acts as user, so a user-scoped collection is writable from it without opening that collection to the world. A declared job belongs to nobody and acts as the app itself. Keep the route short either way — hand slow work to a record that the next run picks up.

Everything above belongs to a user. Some work doesn’t: a nightly digest of yesterday’s orders has to run whether or not anybody signs in. Those jobs are declared, in schedule.json, and installed on deploy — the app never creates them at runtime, so no visitor can mint one:

{
"jobs": {
"nightly-digest": {
"at": { "hour": 7, "zone": "America/New_York" },
"email": { "to": "team@example.com", "subject": "Yesterday", "text": "" }
},
"hourly-rollup": {
"every": 3600,
"collection": "rollups",
"put": { "kind": "hourly" }
},
"monday-report": {
"at": { "day": "mon", "hour": 9, "zone": "Europe/London" },
"call": "/tasks/report"
},
"nightly-tidy": {
"at": { "hour": 3 },
"call": "/tasks/tidy"
}
}
}

The key is the job’s name. Adding one adds the job on the next deploy, changing one changes it, removing one removes it. A job whose declaration is unchanged keeps its clock — a deploy doesn’t reset the countdown.

Rule Why
an every (minimum 60s) or an at clock, never both a declared job repeats; a one-off belongs to a user
a digest, a report, an invoice run wants the at clock “every 86400 seconds” drifts off the hour a person expects, and shifts when the clocks change
a put must target a shared collection the job belongs to nobody, so there is no user partition to write into
an email must carry its own to there is no signed-in user to fall back to
a call arrives with "user": null the job belongs to nobody — this is the app’s cron in the ordinary sense
at most 20 declared jobs

These rules are checked when schedule.json is written, so a bad declaration is a build error rather than something you discover at 3 a.m.

Nothing here changes or removes a record. That’s deliberate: “this stops counting after a week” is a read-time filter, and a filter stays correct whether or not a timer ever fired.

import { collection } from "data";
const cutoff = Date.now() - 7 * 86400 * 1000;
const { items } = await collection("listings").list({ createdAt: `gt:${cutoff}` });

Store an expiresAt (or lean on createdAt) and ask for what is still live — see Data. A row that has aged out simply stops coming back, with no job to schedule, no sweep to drain, and nothing to go wrong at 3 a.m. Clear the old rows from the app itself, when someone is looking at them.

A job may carry an email — the same subject + html/text (and optional to, cc, bcc, replyTo, headers) that Email /send accepts. to defaults to the scheduling user’s own address, which is what a personal reminder wants:

await schedule({
collection: "reminders",
put: { message: "Renew the domain" },
at: 1750000000000,
email: { subject: "Renew the domain", text: "It lapses tomorrow." },
});

A job does the same thing every time it fires, so a drip sequence is one job per message, each with its own in — not one repeating job.

The payload is validated when you schedule (same rules as a live send, capped at 32 KB), the send rides the same per-user throttle and daily quota as a live send, and a send that fails is retried with the job’s own backoff. It requires the Email capability on — without it, an email payload is a 400 at schedule time. Turning Email off later stops the sends; the records keep being written.

const jobs = await listJobs(); // → [{ id, collection, put, email, call, nextRun, lastRun, repeat, attempts, lastError, createdAt }, …]
const one = await getJob("job_abc"); // → the job, or null once it has fired or been cancelled
const gone = await cancelJob("job_abc"); // → true, or false if it was already gone

listJobs() hands back the array — the {"jobs": […]} envelope in the table above is the wire, and the module unwraps it. Jobs come back soonest-first.

repeat says how the job recurs, and it is null for a one-off:

{ kind: "every", seconds: 3600 }
{ kind: "clock", clock: { hour: 8, minute: 30, zone: "Asia/Kolkata", day: null, date: null } }
Limit Value
Pending jobs per caller 1000 — a repeating job counts once, however often it fires
New jobs per caller 60 a minute (429 past that)
Record size 32 KB (413 past that) — the email payload gets its own 32 KB
Jobs fired per alarm 100, re-arming to finish the rest
Minimum repeat interval 60 seconds

Scheduling past the job cap is a 400 telling you to cancel some first, so the cap bites only if you mint a job per record rather than one repeating job.

A fired put is written into the collection you named — with two extra fields, _scheduled (the job id) and _firedAt — so you read the result with the Data API you already know:

const { items } = await collection("reminders").list({ sort: "-_firedAt" });

Those two fields are the platform’s: a record of your own that names one is refused, but you can filter and sort on them like any other field — which is how you tell a delivered record from one your app wrote.

If Realtime is also on, every record a job writes is pushed to any ?data=<collection> subscriber the moment it happens — so a timer becomes a live update with no polling. A one-off job is removed after it fires; a repeating job re-arms to its next slot (missed runs are skipped, not stacked, so a long-idle app never fires a burst on wake).

A run can fail — the database is briefly unreachable, or the collection changed shape under a job scheduled days ago. A failed job is retried with a widening backoff, doubling from 10 seconds up to an hour; a repeating job never backs off past its own interval, so a job set to run every five minutes retries within the five minutes it already has. Each failed attempt bumps attempts and records the reason in lastError, both readable on the job:

const job = await getJob("daily-nudge");
if (job && job.attempts > 0) console.warn("last run failed:", job.lastError);

A job that cannot succeed eventually gives up. After 8 failed attempts it stops and comes back with "nextRun": null, keeping the lastError that stopped it. It is still in the list — a job that went wrong is something you can find, not something that quietly stopped existing — but it will not run again on its own, and nothing but a fresh schedule revives it.

So nextRun is the honest question to ask of a job: a number is when it next runs, null means never. Render that as a failed reminder rather than a pending one.

const job = await getJob("daily-nudge");
if (job && job.nextRun === null) console.error("gave up:", job.lastError);

A fired put is idempotent: its record id is derived from the job and its due time, so a retry rewrites the same record rather than adding a second one. A job never doubles up, even if the platform retries it.

Every owner’s timers live in their own scheduler object — not a shared table with an owner column. A job is invisible and uncancellable from any other owner’s scheduler, and the app’s declared jobs sit in an object of their own that no request can address.

With Sign-in on, schedule() needs a session and each user gets their own timers. Scheduling always needs a signed-in caller — a timer is not anonymous work, and a signed-out one throws a ScheduleError with .status 401. This matches the Data and Files APIs. A job writes wherever the collection’s scope says a write by that owner lands, so a timer against a shared collection surfaces for every member — and a job can never reach a record its owner could not have written by hand.

Scheduled jobs are backed by a Durable Object in your account, using its durable alarm — so a pending timer survives restarts, and an idle scheduler costs nothing until it’s due. The browser API always turns on Sign-in because every timer needs an owner. Data is required only when a job writes a record; email is required only when it sends one. There’s no new account permission to grant — just ask for something that needs to happen later and the agent turns it on.

Turning Scheduled jobs off removes the scheduler on the next deploy, and its pending jobs go with it — a disabled capability never keeps firing in the background.