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.
The API
Section titled “The API”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} |
Two kinds of job
Section titled “Two kinds of job”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.
Declare where it lands first
Section titled “Declare where it lands first”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.)
What a job does
Section titled “What a job does”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 nowawait 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 nameawait 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 |
At a clock time
Section titled “At a clock time”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 zoneawait schedule({ id: "morning-list", at: { hour: 8, minute: 30, zone: "Asia/Kolkata" }, email: { subject: "Your morning list", text: "Open the board." },});
// 5pm every Fridayawait 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.
Running your own code
Section titled “Running your own code”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/:
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 payloadawait 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.
A job for the whole app
Section titled “A job for the whole app”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.
Expiry is a filter, not a timer
Section titled “Expiry is a filter, not a timer”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.
Email when it fires
Section titled “Email when it fires”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.
List and cancel
Section titled “List and cancel”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 cancelledconst gone = await cancelJob("job_abc"); // → true, or false if it was already gonelistJobs() 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 } }Limits
Section titled “Limits”| 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.
What happens when it fires
Section titled “What happens when it fires”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).
When a run fails
Section titled “When a run fails”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.
Isolation
Section titled “Isolation”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.
How it’s built
Section titled “How it’s built”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.