One */15 cron, many legs, each isolated in its own waitUntil

Run every scheduled task as its own ctx.waitUntil(promise.catch(report)) so one failing leg can never take the others down or throw out of scheduled().

Queues & Crons

· Chapter

3

·

3

min read

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 runs scheduled() 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.withSentry wraps fetch only. 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