Coming back later
The map is at The guide. This page is the whole of scheduling: the row that records when, the moment it goes off, and the rule that decides what it means.
An app has no cron and no worker sitting awake. What it has is a component:
anything in its store can carry a wake, and the store comes back for it.
A wake is a row
await apply({
entity: { eid: '$fern' },
doc: { title: 'Fern' },
plant: { window: 'north' },
wake: { at: '2026-09-20T09:00:00Z', note: 'water me' },
})That is the whole request. wake has four columns and every one is optional
except the moment:
at— when to come back, as an instant. Absent means nothing is owed: a spent one-shot, or a schedule someone paused.every— how it repeats, when it does. See below.note— a line for whoever is woken: why you asked to be.target— another entity this wake is about, when it is not about the one carrying it.
Any entity may carry one. A plant, a lease, an invoice, a draft nobody has sent — the wake goes on the thing itself, so there is no separate table of jobs to keep in step with your data.
The moment it goes off
At at, the app's own store writes two things on that row, in one transaction:
fired: { at }— the instant it went off.wake.at— moved on to the next occurrence, or cleared when there is no next one.
So a one-shot fires once and leaves wake.at empty; a recurring wake fires and
is already owed again. fired is the component that records "this has gone
off", and it is what your rules watch.
Nothing polls. The store asks the platform to wake IT at the earliest instant it owes, so an app with no schedules costs nothing and an app with one is woken for that one. An app that was quiet for a week still fires: the schedule is a row, not a timer somebody has to have been holding. Occurrences missed while nothing was running collapse into one firing, which then moves past now — a daily sweep that was owed four times is swept once, not four times in a row.
What a firing means is a rule
A wake carries no action. What happens when it goes off is whatever your app's
rules say about fired, and a rule is a vocab.json entry like a component —
no code, nowhere to deploy it:
{ "$defs": {
"plant": { "properties": { "window": { "type": "string" } } },
"watered": { "properties": { "by": { "type": "string" } } },
"waters": {
"rule": true,
"description": "a plant whose wake has fired has been watered",
"match": ".plant, .wake, .fired, +!watered, +watered.by=wake"
} } }The match is a filter with two extra marks:
+compwrites that component (+watered.by=wakewriteswatered { by: "wake" }).+!compis a gate: the rule only fires while that component is absent, and after it writes, it is not. That is how a rule fires once per thing rather than on every later write to the row.
Everything else — .plant, .wake, .fired — reads the way it reads in any
query. The rule runs inside the transaction the firing is part of, so the row
comes back out of apply already carrying what the rule wrote, and a page
watching that row sees one change and not two.
A rule can watch anything, not only a firing. .invoice, .paid, +!receipt is
the same shape about a different moment.
Repeating
every sets how, in three forms:
30s 2m 90m 2h 1d 3d 1w a duration, from the last one
0 9 * * 1-5 five cron fields
@hourly @daily @weekly @monthly the named onesA duration keeps its own cadence: every: '1d' counted from the at you gave,
so a wake first owed at 09:00 stays a 09:00 wake. A cron line names calendar
positions — minute, hour, day of month, month, day of week, with wildcards,
ranges, steps and lists, Sunday as 0 or 7.
A cron line may end with a zone, and that is the only place a zone is written:
20 4 * * * America/Detroit
@daily Asia/KathmanduWithout one it is UTC. Daylight saving is honoured: a time that does not exist in spring moves forward by the gap, and one that happens twice in autumn fires at the first.
Writing every with no at starts it at its first occurrence, so
wake: { every: '@daily' } is a schedule that begins tomorrow rather than a row
that never fires.
Pausing, resuming, stopping
wake: { at: null } paused, and it keeps its every
wake: { at: '2026-10-01T09:00:00Z' } owed again at that moment
wake: null the schedule is gone
entity: { …, tombstone: {} } so is everything else about itA paused wake is a row that is still there and simply owes nothing — which is
why pausing is a column and not a delete. fired stays where it was: it is
history, not state.
A command, later
An app's own commands (the tools.json at its root) are things the store can
run itself, and asking for one is a row like everything else. A call names the
command and its arguments; a wake on that same row records when:
let [digest] = await query('.tool.name=send_digest')
await apply({
entity: { eid: '$monday' },
call: { to: digest.entity.eid, args: '{"list":"weekly"}' },
wake: { at: '2026-09-21T09:00:00Z', every: '@weekly' },
})call.to is a command's own row. A deploy plants one per declared command,
carrying tool { name, description }, so the name is what you look it up by —
and a to naming no command this store knows is left where it is rather than
refused, since another runner may own it. A mistyped name is a call that never
runs.
A call with no wake runs the moment it is written. One with a wake waits, and
the firing is what runs it — so the result lands beside the request: a result
pointing back at the call, an execution recording done or failed, and
whatever the command itself wrote. Nothing is lost if the app was quiet: the
call is a row, and so is the moment it is owed.
A call with every is a standing request and is never run itself. Each firing
writes its own call — call { to, args, source }, the source naming the
schedule — so a weekly digest is fifty-two calls and fifty-two answers, never
one result re-run. The same instant twice is the same call, so a re-delivered
alarm changes nothing.
A world that keeps going
An idle game is the hardest version of this: a few minutes between ticks, and the world has to go on while nobody has the page open. It is one row — the world carries the request and the cadence together:
let [advance] = await query('.tool.name=advance')
await apply({
entity: { eid: '$world' },
world: { name: 'Eldermoor' },
call: { to: advance.entity.eid, args: '{}' },
wake: { at: new Date().toISOString(), every: '5m' },
})advance is the app's own command, declared in tools.json like any other:
{ "advance": {
"description": "Advance the world to now",
"input": {},
"apply": { "entity": { "eid": "$tick" }, "tick": {} } } }Every five minutes the store wakes itself, writes the call for that instant and runs it — with nothing open and nothing connected.
A stretch nobody was there for is one firing, not one per minute. Half an
hour when nothing was awake to notice leaves one advance, and the cadence
carries on from where the catch-up left it: 09:05, then 09:40, then 09:45. That
is the whole of catching up, and it is why a five-minute world costs the same
whether it was watched all day or not at all.
How long it has been is on the rows. The schedule row carries fired { at } —
the instant it last went off — and each firing's own call is stamped with when
it was written, so the stretch that just passed is the gap between the last two.
What the world earned over that stretch is the span times whatever a minute is
worth, and that arithmetic is not the tick's: a rule and a command both write
rows, and neither multiplies. Fold the span where code runs — the page as it
draws, or the app's own worker.js. Which is the other reason the catch-up is
cheap: one firing, one span, one fold, instead of a thousand replayed minutes.
The repeating request is the call, not a rule. A declared rule that writes
onto the row it matched has to gate itself (+!comp above), and a gate is what
makes a rule fire once per thing — so "every five minutes" belongs to the
schedule, and a rule is for what one firing means about that row.
Where a firing runs
- Inside the app's own store, the same object a page writes through — not a
request to your
worker.js, which runs only when something asks it for a page. Nothing of the app's own JavaScript runs on a firing: what a tick can do is what a rule and a declared command can do, which is write rows. There is no backgroundenvto reach from it, and so no secret it could carry. - On the app's own declarations. The rules in its
vocab.jsonand the commands in itstools.json, as the last deploy left them. - Under the Durable Object alarm's budget, which is what a store object's own clock is. Cloudflare gives an alarm handler a maximum wall time of 15 minutes, and the object 30 seconds of active CPU per invocation, raisable to five minutes (300,000 ms) on the Workers Paid plan (https://developers.cloudflare.com/workers/platform/limits/, read 2026-09-19). The 50 ms an app's own worker gets per request (see Code of your own) is a different budget, and not the one a firing spends.
- At least once. An alarm can be delivered twice; the firing is guarded on
the wake's own
at, so the second delivery finds the occurrence taken. A scheduled call is derived from the schedule and the instant, so the same instant twice is the same call.
Why there is no cron trigger
An app's worker cannot ask for a Cron Trigger and cannot ask for a queue, and
app_deploy refuses a wrangler.toml naming either. There is no scheduled()
to export: nothing forwards one, and a worker that has one is never called on
it. Both would be a second place where "later" lives, and you would then have to
keep it in step with your data by hand. A wake row is in the same store as the
thing it is about, readable by the same query, editable by the same page,
restored by the same store_restore. Ask for later where the thing lives.
