The Job That Runs While You Sleep

Lesson 0009 · Glossary · 1 · 2 · 3 · 4 · 5 · 6 · 7 · 8 · Next: the Bun track’s globs & scheduling

The win, in one line: Lesson 0006’s release tracker — the script that fetches the latest version and remembers it in SQLite — now runs without you: a Cloudflare Cron Trigger fires it, it writes the answer to D1, and no laptop needs to be awake.

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.

1. The scheduled model

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)

2. Declare the schedule

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.

3. Write the handler

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.

4. Run it locally, on demand

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

5. Two gotchas I hit so you don’t

Gotcha A — datacenter IPs get rate-limited. My first source was the GitHub API; it answered 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.
Gotcha B — local table ≠ cloud table. I applied the schema --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

6. Deploy and watch it tick

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.

The map

bash (yesterday’s scheduling)Cloudflare Cron + Worker + D1
crontab -e + a script on one machinetriggers.crons in wrangler.jsonc
the job never runs if the machine sleepsthe edge runs it, any OS, any timezone (UTC)
mail yourself the output, or write a log filewrite D1, query it later (or via your API)
per-OS cron/launchd syntaxone worker, one expression, everywhere

Practice

Recall check (from memory, no peeking)

1. What makes a cron fire your Worker?

A triggers.crons entry plus a scheduled(controller, env, ctx) export. Without the handler, the events are dropped.

2. The cron works locally, but deployed it 500s with "no such table". Why?

--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.

3. What does controller.scheduledTime give you?

new Date(controller.scheduledTime) — the fire time that was planned, independent of when the invocation actually starts.

Hands-on: schedule yesterday’s tracker

  1. In your Lesson 0008 project (or the d1 track’s d1-demo), add the releases (repo, tag, seen_at) table to schema.sql and apply it with both flags (Gotcha B).
  2. Add "triggers": { "crons": ["*/5 * * * *"] } to wrangler.jsonc.
  3. Paste the Section 3 handler into src/index.ts alongside your fetch handler.
  4. 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).
  5. 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.

Read this next

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.