The answer. Don't give every background job its own cron. Register one */15 * * * * trigger and fan out inside scheduled(): each job becomes ctx.waitUntil(job(env).then(log).catch(report)). The .catch is the contract. A leg that throws gets logged and tagged in Sentry, then swallowed, so the other legs still run and scheduled() itself never rejects. Jobs with a different cadence read the clock themselves (a 72-hour scan keeps its own next_due_at; a daily job checks for the 06:00 UTC window), so the tick stays dumb and the schedule lives next to the job. Two rules keep it safe: never await a leg inline, and wrap anything that can throw synchronously (constructing a client, a missing binding) in an async IIFE so it becomes a rejection the .catch can see.
The pattern.
function report(task: string, err: unknown) {
console.error(`scheduled ${task} threw`, err);
Sentry.captureException(err, { tags: { task } });
}
export default {
async scheduled(_c: ScheduledController, env: Env, ctx: ExecutionContext) {
if (!mutationsAllowed(env)) return; // fail-closed gate first
const leg = (task: string, run: () => Promise<unknown>) =>
ctx.waitUntil(
(async () => run())() // sync throws -> rejection
.then((s) => console.log(task, s))
.catch((err) => report(task, err)),
);
leg("publisher", () => publishDue(env));
leg("janitor", () => failStaleRuns(env));
leg("reminders", () => sendReminders(env));
if (env.SCANS_ENABLED === "true" && new Date().getUTCHours() === 6)
leg("scan", () => runDueScans(env)); // daily window, same tick
},
};
The receipt. HarperFlow's API Worker runs one prod cron, */15 * * * *. As of late August 2026 its scheduled() fans out into 18 ctx.waitUntil legs: 13 always-on (due publisher, care-package delivery, stale-run janitor, weekly autopilot planner and dispatcher, drip email, disconnect reminders, breakout-topic radar, four health checks, the heartbeat) and 5 behind rollout flags. Every leg has its own .catch. A regression test pins that a synchronous client-construction failure in one leg stays isolated from the rest. We didn't measure per-leg runtime; nobody has needed to.
Watch out.
- Wrangler environments don't inherit
[triggers]. Without an[env.prod.triggers]block the prod Worker never runsscheduled()at all, and nothing tells you. - Two legs that self-call the Worker over HMAC-signed internal routes must not sign identical bodies in the same second; a replay guard keyed on signature will 409 one of them, nondeterministically.
Sentry.withSentrywrapsfetchonly.scheduled()exceptions are invisible beyond tail logs unless you capture them yourself.
Related: cron-heartbeat-dead-mans-switch · cron-gate-outage-kill-switch-returned-early