Skip to content
runlot

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.

runlot.json
{
  "name": "my-app",
  "main": "src/index.ts",
  "triggers": { "crons": ["30 18 * * *"] }
}
src/index.ts
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.

runlot.json
{
  "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.

FormMeaning
*every value
5exactly 5
1-51 through 5
*/15every 15th value
0,300 and 30
jan, monmonth 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

FreePro
Granularity1 minute1 minute
Schedules per organization5250
Schedules per project55
Wall-clock per run, schedule under 1 hour30 seconds30 seconds
Wall-clock per run, schedule 1 hour or longer5 minutes5 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 cron
0 * * * *	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

SucceededYour handler ran and returned.
FailedYour 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.
ErrorThe 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.
SkippedThe 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.

On this page