Contents11

The first wall I hit building scheduled work on Cloudflare Workers was the free plan’s cron trigger cap: five per account. Cut one cron per purpose within a single service — patrol, send, daily maintenance — and you reach five fast. Want another service on the same account, and there’s nothing left.

I built and shipped a price watcher called Yasugoro on Workers + D1. Its scheduled work spans wildly different cadences: sending notifications every minute, patrolling every five, running maintenance once a day. To keep all of it under five crons — and shareable with other services — I collapsed cron into one per-minute schedule that branches on time inside the tick. Here’s how it’s wired.

One per-minute cron, branching on time inside the tick

The move is simple: register a single * * * * * (per-minute) cron, and inside the scheduled() handler check the current time to decide what runs this minute. No new cron per purpose.

// the only cron expression (per minute); keep it in sync with wrangler.toml crons
export const CRON_TICK = '* * * * *';

export async function runScheduledTick(env: Env, now: Date): Promise<void> {
  if (isPatrolTick(now))      await runPatrol(env, now);       // every 5 min
  if (isKaidokiEveTick(now))  await runKaidokiEve(env, now);   // JST 23:50
  if (isKaidokiDayTick(now))  await runKaidokiDay(env, now);   // JST 00:00
  if (isHousekeepingTick(now)) {
    await runHousekeeping(env, now);                            // JST 03:30
    await runSaleIngest(env, now);
  }
  if (isRankingIngestTick(now)) await runRankingIngest(env, now); // JST 04:00
  await runDispatchCron(env, now);                              // every minute (send)
}

Each per-minute tick runs only the jobs whose predicate matches that minute. Six distinct schedules, and the cron budget spent stays at one. scheduled() sees it through with ctx.waitUntil(runScheduledTick(env, new Date())).

flowchart TD
  Cron["cron * * * * * one per minute"] --> Tick[runScheduledTick now]
  Tick --> Dispatch[send: every minute]
  Tick --> Patrol[patrol: minute%5==0]
  Tick --> Eve[buy-timing eve: UTC 14:50]
  Tick --> Day[buy-timing day: UTC 15:00]
  Tick --> House[maintenance+ingest: UTC 18:30]
  Tick --> Rank[ranking ingest: UTC 19:00]

Write the branch predicates in UTC

Time branching is written with Date’s UTC methods. The time handed to scheduled is UTC, so writing it as if it were local drifts. Jobs that should run on Japan time are converted as JST = UTC+9.

export const isPatrolTick      = (n: Date) => n.getUTCMinutes() % 5 === 0;
export const isKaidokiEveTick  = (n: Date) => n.getUTCHours() === 14 && n.getUTCMinutes() === 50; // JST 23:50
export const isKaidokiDayTick  = (n: Date) => n.getUTCHours() === 15 && n.getUTCMinutes() === 0;  // JST 00:00
export const isHousekeepingTick = (n: Date) => n.getUTCHours() === 18 && n.getUTCMinutes() === 30; // JST 03:30

“Every five minutes” is getUTCMinutes() % 5 === 0; a specific time is an hour-and-minute match. Pulling each predicate into its own function keeps runScheduledTick readable — a list of “if it matches, call it.” Tests can pass new Date('...Z') and check each branch in isolation.

Reliability is the same as several dedicated crons

You might worry that collapsing to one per-minute cron makes daily jobs flaky. It doesn’t — because the dependency is identical.

“A dedicated cron that fires at UTC 18:30” and “a per-minute cron whose UTC 18:30 firing you check” both depend, equally, on Cloudflare firing the cron for that minute. If Cloudflare drops a firing, the dedicated cron is missed too; if the per-minute tick arrives, the time check holds. Collapsing adds no new uncertainty.

The one thing that matters operationally is keeping wrangler.toml’s [triggers] crons and the cron constant in code character-for-character identical. Drift there and it either doesn’t fire or registers more than you meant. yasugoro keeps a single test that cross-checks the two so drift fails the build.

Fit each tick under the free-tier subrequest limit (50)

The free plan has a separate limit: at most 50 outgoing fetches (subrequests) per invocation. Collapsing crons doesn’t help here — if one tick’s work exceeds 50, it fails. So for each job you back the batch size out of “how many subrequests does one item cost?”

  • Patrol: roughly one request per item, so a batch of 40 (PATROL_BATCH_SIZE) leaves headroom under 50.
  • Send: one item costs a pre-send re-check plus a delivery — up to two subrequests. So the batch is capped at half the send constant (PUSH_BATCH_SIZE / 2).
// send: one item = re-check + deliver = 2 subrequests -> halve the batch
const batchSize = Math.floor(PUSH_BATCH_SIZE / 2);
await runDispatch(deps, batchSize, now);

A job that can’t clear its targets in one batch rotates them. Yasugoro’s ranking ingest splits its target categories into a few per tick, so firing at the same time each day still keeps one run to a handful of categories and sweeps the whole set over several days (effectively weekly). You clear the 50-subrequest wall by not doing everything at once.

Resolve job dependencies by call order

When jobs within a tick depend on each other, the order of calls in runScheduledTick resolves it. Yasugoro puts sending last.

That way notifications enqueued by an earlier job in the same tick (buy-timing, say) get picked up by the same run’s send, without waiting for the next tick. Reverse the order and those enqueued items sit for at least a minute. Splitting the heavy daily work — maintenance and ranking ingest — across different times (UTC 18:30 and 19:00) comes from the same instinct: don’t concentrate load and subrequests into one tick.

Wrapping up

When you want to run several schedules on Cloudflare Workers without eating the cron cap, collapsing to a single per-minute cron that branches on UTC time inside the tick was the straightforward path. Reliability matches lining up dedicated crons, and you spend one trigger instead of many. What actually bites is the other free-tier limit — 50 subrequests per run — which you handle by backing out batch sizes and not doing everything at once.

What this cron actually drives — how the notifications get delivered without any login — is in delivering Web Push by anonymous device token. The running thing is Yasugoro, which you can poke at with no login.

FAQ

Why collapse into one cron instead of several dedicated ones?

Because Cloudflare Workers’ free plan caps cron triggers at five per account. Run several services on one account and a single service burning five triggers leaves no room for the rest. yasugoro registers exactly one per-minute cron (* * * * *) and, inside it, checks the UTC time to dispatch each job. It spends only one trigger, so other services can share the budget.

Does a per-minute tick with time branching run daily jobs reliably?

Yes. “A dedicated cron fires at that minute (say UTC 18:30)” and “a per-minute cron fires at that minute and you check the time” depend on the exact same thing: Cloudflare firing the cron for that minute. So daily-job reliability equals a dedicated cron — if a firing is dropped, both are affected the same way. Write the predicates with getUTCHours() / getUTCMinutes(), converting JST as UTC+9.

How do you stay within the free-tier subrequest limit?

Outgoing fetches (subrequests) per invocation are capped at 50 on the free plan. Count how many subrequests one item costs, then back out the batch size. yasugoro patrols 40 items per batch, and for sending — where one item costs a re-check plus a delivery, i.e. two subrequests — it caps the batch at PUSH_BATCH_SIZE/2. Any ingest that can’t finish in one pass rotates its targets and covers the whole set over several days.

Can a job enqueued this tick be processed in the same run?

Yes, if you order the calls right. yasugoro runs sending (the outbox dispatch) last in the tick. That way notifications enqueued by an earlier job in the same tick (like buy-timing) get picked up and sent in the same run. Reverse the order and those enqueued items wait until the next tick, so dependent jobs are resolved by call order.