Cloudflare Workers cron triggers: interval, limits and local time
Cron Triggers run a Worker's scheduled() handler on a cron expression. For most periodic work on Cloudflare they are the right tool, and this page will not try to talk you out of them. It gives the limits as Cloudflare documents them, shows how to run a job at a local time with a trigger alone, and covers the cases where calling the Worker from outside is simpler.
The limits
From Cloudflare's Cron Triggers and Limits pages, checked on 15 October 2026:
| Workers Free | Workers Paid | |
|---|---|---|
| Cron Triggers | 5 per account | 250 per account |
| Finest schedule | every minute | every minute |
| Time zone | UTC | UTC |
| CPU time per invocation | 10 ms | 30 seconds for intervals under an hour, 15 minutes for an hour or more |
| Wall time per invocation | 15 minutes | 15 minutes |
Minimum interval: one minute. The expression has five fields and no seconds, so * * * * * (which Cloudflare's own examples use) is as often as a trigger fires. Cloudflare also accepts L, W and # for "last day", "nearest weekday" and "nth weekday of the month", which plain cron does not.
Three more things from the same pages:
- Changes are not instant. Adding, changing or deleting a trigger "may take several minutes (up to 15 minutes) to propagate".
- The limit is per account, not per Worker. Five triggers on Free are shared by every Worker you have.
- The history is short. Cron Events in the dashboard keeps the 100 most recent invocations; Workers Logs keeps more.
Running at a local time with one trigger
"Cron Triggers execute on UTC time", and there is no setting to change it. A job at 09:00 in Paris is at 07:00 UTC in summer and 08:00 UTC in winter, so no single UTC expression is right all year (see Run a cron job in your timezone for the dates).
You can still do it on Cloudflare alone. Fire the trigger every hour, and let the handler ask what time it is in Paris:
import { sendMorningDigest } from './digest';
const ZONE = 'Europe/Paris';
const LOCAL_HOUR = 9;
/** The hour (0–23) on the wall clock in `zone` at the instant `at`. */
function hourIn(zone: string, at: Date): number {
const hour = new Intl.DateTimeFormat('en-GB', { timeZone: zone, hour: '2-digit', hourCycle: 'h23' }).format(at);
return Number(hour);
}
export default {
async scheduled(controller: ScheduledController, env: Env, ctx: ExecutionContext) {
// The trigger fires every hour, in UTC. Only the run that lands in the
// 09:00 hour in Paris does the work: 07:00 UTC in summer, 08:00 UTC in winter.
if (hourIn(ZONE, new Date(controller.scheduledTime)) !== LOCAL_HOUR) return;
await sendMorningDigest(env);
},
} satisfies ExportedHandler<Env>;
sendMorningDigest is your own code. The handler uses controller.scheduledTime, the time the run was scheduled for, rather than the clock, so a run that starts a few seconds late still counts as the 09:00 one. It costs one trigger and one hourly wake-up, and it does the work once a day, all year: 09:00 is never inside the hour that goes missing or repeats when the clocks change. That would not be true of a job at 02:30.
You can check it before you deploy. wrangler dev exposes /cdn-cgi/local/scheduled, and its time parameter sets scheduledTime. With npx wrangler dev running, from another terminal:
for at in 2026-10-16T07:00:00Z 2026-10-16T08:00:00Z 2026-10-26T07:00:00Z 2026-10-26T08:00:00Z; do
ms=$(node -e "console.log(Date.parse(process.argv[1]))" "$at")
curl -s "http://localhost:8787/cdn-cgi/local/scheduled?cron=0+*+*+*+*&time=$ms&format=json"
echo " <- $at"
done
Each call answers {"outcome":"ok","noRetry":false}, and sendMorningDigest runs for two of the four: 16 October at 07:00 UTC (09:00 in Paris, summer time) and 26 October at 08:00 UTC (09:00 in Paris, winter time). The other two are 10:00 and 08:00 in Paris, and the handler returns without doing anything.
If this is all you need, you do not need anything else.
When to call the Worker from outside instead
A scheduler outside Cloudflare can call your Worker over HTTP at a set local time. It is worth it when:
- You have run out of triggers. Five per account on Free, shared by all your Workers.
- You want a run history with answers in it — the status code and body your Worker returned, each attempt, and failures retried on their own.
- The schedule should change without a deploy, and take effect at once rather than within 15 minutes.
- The schedule is not hourly-aligned, or there are many local times to keep track of, and an hour check in code stops being simple.
With Clockwork, the Worker gets a fetch() handler with a shared secret instead of a scheduled() one:
import { sendMorningDigest } from './digest';
export default {
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
const { pathname } = new URL(request.url);
if (request.method !== 'POST' || pathname !== '/jobs/morning-digest') {
return new Response('Not found', { status: 404 });
}
if (request.headers.get('authorization') !== `Bearer ${env.JOB_SECRET}`) {
return new Response('Unauthorized', { status: 401 });
}
await sendMorningDigest(env);
return Response.json({ ok: true, attempt: request.headers.get('x-clockwork-attempt') });
},
} satisfies ExportedHandler<Env>;
Store the secret with npx wrangler secret put JOB_SECRET (for wrangler dev, put JOB_SECRET=... in .dev.vars), deploy, and create the job with the same secret. You need an API key with the clockwork scope; see API Keys.
curl -X POST https://api.vernesoft.com/v1/clockwork/jobs \
-H "Authorization: Bearer $VERNE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Morning digest (Worker)",
"schedule": "0 9 * * 1-5",
"timezone": "Europe/Paris",
"url": "https://morning-digest.your-subdomain.workers.dev/jobs/morning-digest",
"method": "POST",
"headers": { "Authorization": "Bearer '"$JOB_SECRET"'" }
}'
The job runs at 09:00 Paris time on weekdays, all year. Clockwork waits 30 seconds for an answer; a 5xx, a 429 or no answer is retried twice, one and five minutes later, with X-Clockwork-Attempt set to 2 and 3, and a 4xx is not retried. Every attempt is in the job's run history. See Retries and, for the rules on the two nights a year the clocks change, Timezones and daylight saving.
The secret is stored in the job's headers. Anyone who can read your Clockwork jobs — your dashboard, or an API key with the clockwork scope — can read it, so use a secret that opens this one route and nothing else. To rotate it, PATCH the job's headers.
Where to go next
- Run a cron job in your timezone, with daylight saving — the general case, with Node.js and Python.
- Vercel cron on the Hobby plan: limits and an alternative.
- GitHub Actions schedule in your local timezone.
Scheduled jobs are part of Nautilus by Verne Software, sign-in and user management for SaaS teams; the same scheduler runs its automatic deletion of inactive accounts. One plan, priced by active users, hosted in France. Create a free account or see the plans.