The first wall you hit scheduling work on Cloudflare Workers is the free plan's cron trigger cap: five per account. Cut one cron per purpose in a single service and you reach five fast — and there's nothing left for another service on the same account.
The fix is simple: collapse cron into one per-minute schedule and branch on time inside scheduled(). That runs six different schedules while spending a single cron trigger. I did this on a price watcher I built, so here are the essentials.
One per-minute cron, branching on time inside the tick
Register a single * * * * * (per-minute) cron and, in the handler, decide what runs this minute.
export const CRON_TICK = '* * * * *'; // keep in sync with wrangler.toml crons
export async function runScheduledTick(env: Env, now: Date): Promise<void> {
if (isPatrolTick(now)) await runPatrol(env, now); // every 5 min
if (isKaidokiDayTick(now)) await runKaidokiDay(env, now); // JST 00:00
if (isHousekeepingTick(now)) await runHousekeeping(env, now); // JST 03:30
await runDispatchCron(env, now); // every minute (send)
}
// Worker entry
export default {
async scheduled(_e: ScheduledController, env: Env, ctx: ExecutionContext) {
ctx.waitUntil(runScheduledTick(env, new Date()));
},
};
Each per-minute tick runs only the jobs whose predicate matches. Add purposes and the cron count stays at one.
Write the branch predicates in UTC
The time handed to scheduled is UTC. Write it as local and it drifts, so use Date's UTC methods and convert anything that should run on Japan time as JST = UTC+9.
export const isPatrolTick = (n: Date) => n.getUTCMinutes() % 5 === 0;
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
Pulling each predicate into a function keeps runScheduledTick readable — just a list of "if it matches, call it." Tests pass a UTC string like new Date('2026-07-25T18:30:00Z') and check each branch in isolation.
Reliability equals dedicated crons
You might worry a single per-minute cron makes daily jobs flaky. It doesn't — the dependency is identical.
"A dedicated cron firing 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. Drop a firing and the dedicated cron misses too; get the per-minute tick and the time check holds. Collapsing adds no new uncertainty.
The one operational thing: keep wrangler.toml's [triggers] crons and the cron constant in code character-for-character identical. Drift and it won't fire, or registers more than you meant — a single test cross-checking the two catches it.
Fit each tick under the free-tier subrequest limit (50)
The other free-plan limit: at most 50 outgoing fetches (subrequests) per invocation. Collapsing crons doesn't help here — exceed 50 in one tick and it fails. So back each job's batch size out of "how many subrequests does one item cost?"
// send: one item = pre-send re-check + delivery = 2 subrequests -> halve the batch
const batchSize = Math.floor(PUSH_BATCH_SIZE / 2);
await runDispatch(deps, batchSize, now);
One request per item (like patrol) → ~40 per batch; two per item (like send) → half. An ingest that can't finish in one pass rotates its targets a few at a time and sweeps the whole set over several days. You clear the 50 wall by not doing everything at once.
The call-order design that lets a job enqueued this tick be processed in the same run, spreading heavy daily work across separate times, and what this cron actually drives (login-less Web Push) — the full notes are on Aulvem → Aulvem | One Workers cron, many jobs. The running thing is Yasugoro.
Top comments (0)