Scheduled execution
Declare `triggers.crons` in runlot.json and your worker's scheduled handler runs on that schedule.
Add triggers to runlot.json and the worker's scheduled handler runs at the times you name.
{
"name": "my-app",
"main": "src/index.ts",
"triggers": { "crons": ["30 18 * * *"] }
}export default {
async fetch(request: Request, env: Env): Promise<Response> {
return new Response("hello");
},
async scheduled(controller: ScheduledController, env: Env, ctx: ExecutionContext) {
// controller.scheduledTime is the time this run was due for, not the current time.
// controller.cron is the expression that fired.
await rebuild(env);
},
};runlot deploy prints the schedules it is about to register, so you can check them before they run.
Scheduled execution (UTC): 30 18 * * *Naming a schedule
Give a schedule a name with triggers.names, keyed by its expression. The dashboard and runlot cron show the name beside each schedule and each run.
{
"triggers": {
"crons": ["30 18 * * *", "*/2 * * * *"],
"names": { "30 18 * * *": "nightly recompute", "*/2 * * * *": "queue sweep" }
}
}Every key must appear in crons, and a name is one line of at most 80 bytes. Names are optional. A schedule without one is shown by its expression.
The expression
Five fields, minute hour day-of-month month day-of-week, evaluated in UTC.
| Form | Meaning |
|---|---|
* | every value |
5 | exactly 5 |
1-5 | 1 through 5 |
*/15 | every 15th value |
0,30 | 0 and 30 |
jan, mon | month and weekday names |
Sunday is 0 (and 7 is also accepted). If both day-of-month and day-of-week are restricted, a run happens when either matches — this is how cron has always behaved, so 0 0 1 * 1 means "the 1st of the month, or any Monday."
Expressions are parsed when you deploy. A malformed one, or one that can never fire (0 0 30 2 * — February 30th), is rejected with a 400 naming the field. A schedule that is accepted but never runs would be indistinguishable from a broken deploy, so we do not accept one.
Limits
| Free | Pro | |
|---|---|---|
| Granularity | 1 minute | 1 minute |
| Schedules per organization | 5 | 250 |
| Schedules per project | 5 | 5 |
| Wall-clock per run, schedule under 1 hour | 30 seconds | 30 seconds |
| Wall-clock per run, schedule 1 hour or longer | 5 minutes | 5 minutes |
A run that exceeds its limit is cancelled and recorded as a failure. The budget is smaller for frequent schedules on purpose: what costs us is not storing a schedule but waking a project every two minutes.
What to expect
Times are approximate. A run starts at or after the minute you named, not exactly on it. Do not build something that depends on the exact second.
A run that is still going blocks the next one. If a job scheduled every 2 minutes takes 3 minutes, the run that comes due while it is working is dropped — not queued behind it — and recorded with the reason overlap. Without that rule the work would pile up faster than the project could clear it.
Runs that fell far behind are dropped. If the schedule was not served for a while, we run the most recent occurrence once rather than replaying every missed one. An hour of downtime on */2 * * * * would otherwise fire 30 times in a row.
A parked project is woken. Projects stop their process when idle (Stop and resume); a schedule wakes it first and then runs. The wake is part of the run, so an idle project's first scheduled run takes a few hundred milliseconds longer.
A project in cold state runs late. After a long idle period a project's data may be moved off the machine (Cold state and resume). A schedule does not currently pull it back — the run happens after something else brings the project back. If your schedule is the only thing that ever touches the project, keep it hourly or more frequent.
Watching it
The Scheduled runs tab of your project, next to Deployments, is the record. It shows each schedule you declared with the time it fires next, how the last 24 hours went, and a table of past runs: when each started, which expression fired, how it ended, how long it took, and why if it did not run.
The same record is in the terminal.
runlot cron0 * * * * next 2026-09-27T10:00:00Z last failed (exception)
Last 24 hours: 20 ok, 4 failed.
2026-09-27T09:00:00Z 0 * * * * failed (exception) 141ms TypeError: Invalid redirect value, must be one of "follow" or "manual"Every run also leaves a line in the project's logs while the project is up.
runlot logs[runlot] Scheduled run 30 18 * * * finished (1.2s)When your handler throws, the error is on both. The message appears at the end of the run's row and in the logs, and the logs also carry the stack:
[stderr] Uncaught (in scheduled) TypeError: Invalid redirect value, must be one of "follow" or "manual"
[stderr] TypeError: Invalid redirect value, must be one of "follow" or "manual"
[stderr] at Object.scheduled (index.js:42:11)So you do not have to reproduce a failed schedule to find out what it was — read runlot cron for the message, runlot logs for the frames around it.
Logs are a buffer and the oldest lines fall out of it; the history on the tab is kept for 30 days on Free and 365 days on Pro.
How a run can end
| Succeeded | Your handler ran and returned. |
| Failed | Your handler threw, or the worker exports no scheduled. The reason shown is the runtime's own, such as exception, and next to it is the message your code threw. |
| Error | The run never reached your handler — it exceeded the per-run limit (timeout), the worker could not be reached (fire_failed), or an idle project would not wake (wake_failed). This one is ours, not yours. |
| Skipped | The schedule was due and we decided not to run it: overlap (the previous run was still going), too_late (the run came up long past its time), schedule_changed (the project woke carrying a different schedule than the one about to fire). |
A failed or error run emails your organization's admins, at most one message per schedule per day. A skipped run does not: it is the scheduler doing what it was designed to do, and a notification about it would teach you to ignore the next one.
Deploying triggers without main is rejected — a static-assets deploy has no handler to call.
Changing or removing a schedule
The schedule travels with the code. Edit runlot.json and deploy; rolling back to an earlier version also restores that version's schedules. Remove triggers and deploy to stop them.
What is not here
There is no way to trigger a run by hand. For work you need to start on demand, expose a route and guard it with a token from runlot secret set.
A project in cold state still runs late, as described above.