Every lesson so far ended when your script did. Now we leave it running: Cloudflare wakes a Worker up on a schedule, it does the chore you’d otherwise cron-tab to your own machine, and stores the result in the hosted database from Lesson 0007. This is the scheduling slice of your mission, done server-side so it’s truly OS-independent.
A Cron Trigger is a cron expression attached to a Worker. When it fires, the platform invokes your scheduled() handler — no HTTP request, no one watching. The expression is evaluated in UTC, five fields, Quartz-style: *, */n, ranges, names (MON…), L/W. One warning that differs from other cron: weekday numbering is 1 = Sunday through 7 = Saturday — prefer the names.
your laptop (cron) ──▶ needs a process awake, per-OS syntax
Cloudflare Cron Trigger ──▶ fires a Worker in the edge, any OS, any timezone
(Cloudflare docs: Cron Triggers)
One block in wrangler.jsonc. Every deploy replaces the Worker’s schedules with this list (an empty array removes them all):
{
"triggers": {
"crons": ["*/5 * * * *"]
}
}
Watch for: propagation. Schedule changes take up to 15 minutes to reach the whole network after deploy — my first live fire landed well after the next “every-5” tick. Also mind the account ceiling: 5 cron triggers per account on the Free plan (250 on Paid), and Free cron invocations get a 10 ms CPU budget — fine for a fetch + a D1 write, because network wait doesn’t count.
Add scheduled(controller, env, ctx) to the Worker’s default export. Key facts: controller.cron is the exact expression string that fired (use it to branch), and controller.scheduledTime is the scheduled fire time as ms since epoch — parse with new Date(). Everything you know from Lessons 5–8 applies inside: res.ok, typed JSON, prepare().bind().run().
interface Env {
DB: D1Database;
}
const SOURCES = ["bun"]; // npm package names to track
export default {
async scheduled(controller: ScheduledController, env: Env): Promise<void> {
await trackLatest(env, new Date(controller.scheduledTime));
console.log(`[cron ${controller.cron}] wrote latest versions`);
},
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
if (url.pathname !== "/releases") {
return Response.json({ error: "not found" }, { status: 404 });
}
const { results } = await env.DB
.prepare("SELECT * FROM releases ORDER BY seen_at DESC")
.all();
return Response.json({ releases: results });
},
} satisfies ExportedHandler<Env>;
async function trackLatest(env: Env, at: Date): Promise<void> {
const upsert = env.DB.prepare(
"INSERT OR REPLACE INTO releases (repo, tag, seen_at) VALUES (?, ?, ?)"
);
for (const pkg of SOURCES) {
const res = await fetch(`https://registry.npmjs.org/${pkg}/latest`);
if (!res.ok) { // Lesson 5's guard, now in prod
console.error(`fetch fail for ${pkg}: ${res.status}`);
continue; // one bad source never kills the job
}
const { version } = (await res.json()) as { version: string };
await upsert.bind(`npm:${pkg}`, version, at.toISOString()).run();
}
}
With several schedules you get the same handler and branch on the string — if (controller.cron === "0 17 * * sun") …. The runtime waits for the promise you return (up to 15 min wall time); use ctx.waitUntil() only to run extra work in parallel.
wrangler dev exposes a test endpoint for the scheduled handler (no flag needed on current wrangler). Simulate any schedule — and even a specific fire time — then read your rows:
curl "http://localhost:8787/cdn-cgi/handler/scheduled?format=json&cron=*+*+*+*+*"
# → {"outcome":"ok","noRetry":false}
curl "http://localhost:8787/cdn-cgi/handler/scheduled?time=1750000000000"
curl "http://localhost:8787/releases" # → your row, with seen_at
403 from Cloudflare’s edge (they throttle unauthenticated datacenter traffic). Solution: pick sources that tolerate it — the npm registry worked instantly — or authenticate with a token secret.
--local but not --remote, so every cron run (and /releases) failed remotely with no such table: releases. The lesson: migrate your schema to the real database before deploying the job.
wrangler d1 execute d1-demo --local --file=schema.sql
wrangler d1 execute d1-demo --remote --file=schema.sql # don't skip this one
wrangler deploy # prints: schedule: */5 * * * *
wrangler tail d1-release-tracker # live invocations — cron + scheduledTime in the event
Then prove it from outside: wrangler d1 execute d1-demo --remote --command="SELECT repo, tag, seen_at FROM releases;", or watch Cron Events in the dashboard (past-event history can lag ~30 min). With Lesson 0008’s API pattern you can even expose /releases over HTTPS so any script — on any machine — can ask the same question your cron answered.
| bash (yesterday’s scheduling) | Cloudflare Cron + Worker + D1 |
|---|---|
crontab -e + a script on one machine | triggers.crons in wrangler.jsonc |
| the job never runs if the machine sleeps | the edge runs it, any OS, any timezone (UTC) |
| mail yourself the output, or write a log file | write D1, query it later (or via your API) |
| per-OS cron/launchd syntax | one worker, one expression, everywhere |
triggers.crons entry plus a scheduled(controller, env, ctx) export. Without the handler, the events are dropped.--local targets a file under .wrangler/state/; --remote targets the hosted DB. Re-run wrangler d1 execute <db> --remote --file=schema.sql before deploying the job.new Date(controller.scheduledTime) — the fire time that was planned, independent of when the invocation actually starts.d1-demo), add the releases (repo, tag, seen_at) table to schema.sql and apply it with both flags (Gotcha B)."triggers": { "crons": ["*/5 * * * *"] } to wrangler.jsonc.src/index.ts alongside your fetch handler.wrangler dev, then hit /cdn-cgi/handler/scheduled?format=json&cron=*+*+*+*+*, then /releases — a row with seen_at should appear. If the npm source ever 403s, your res.ok guard logs it and moves on (Gotcha A).wrangler deploy; run wrangler tail and wait one tick (up to 15 min propagation); then rejoice: wrangler d1 execute d1-demo --remote --command="SELECT repo, tag, seen_at FROM releases;" shows a row you never typed a single curl for.Check your work (immediate feedback):
curl "http://localhost:8787/cdn-cgi/handler/scheduled?format=json" # {"outcome":"ok"}
wrangler d1 execute d1-demo --remote --command="SELECT count(*) FROM releases;" # ≥1, and it grows by itself
If the count goes up without you doing anything, you’ve built your first unattended job: scheduled, hosted, typed, and readable from any Bun script — the mission’s scheduling slice, closed.
Primary sources for this lesson: Cloudflare docs: Cron Triggers (expression syntax, propagation, removal semantics) and the Scheduled handler reference (ScheduledController, ctx.waitUntil, multi-cron branches).
Next in the D1 track: fold these hosted snapshots into a weekly report your Bun script renders — and meet the Bun track’s Bun.Glob and Bun-side scheduling to complete the loop.