Engineering

Cloudflare cron triggers: the subrequest budget that broke our cron

Eleven cron jobs shared one 50-subrequest budget and failed silently. How we split jobs by urgency, alternated heavy work, and metered outcomes in one call.

Cron triggers are one of the nicest small features of Cloudflare Workers. You add a schedule to your config, export a scheduled() handler, and Cloudflare calls it on time, everywhere, with no server to keep alive. On the free plan you get five of them per account. It's the kind of feature you set up in two minutes and stop thinking about.

That last part is the problem. A cron runs when nobody's watching, and when it fails, it usually fails quietly. We've had three different cron problems across our apps, and every one of them ran for a while before anybody noticed. This post is about those three, and the rules we follow now.

The cron that broke at zero users

Perron, our train-station chat app, has a five-minute cron that does most of its background work. It flushes buffered statistics. It checks the account's quota. It polls the national disruption feed and notifies people who follow affected stations. It works out whose train was late enough that they're owed a refund. And it nudges the moderation queue in case an alarm was lost.

Plus a nightly cron at 03:00 UTC for retention sweeps: deleting expired sign-in codes, old check-in traces, lost-and-found posts that have aged out, operational logs, and the journeys people logged for refunds.

In July, before Perron had real users, we found the five-minute cron had been broken. The commit message is in capitals for a reason: the cron was broken today, at zero users.

Here's what had happened. On the free plan, one Worker invocation gets 50 subrequests. Every fetch() counts, and so does each call the Worker makes to a Durable Object. Our cron had grown to eleven jobs in one invocation, and each job had been sized carefully against that limit. The disruption fan-out was chunked to eight stations at a time. The refund claims were batched ten at a time. Each one, on its own, fitted inside 50.

Nobody had added them up. Together they spent about a hundred against a ceiling of fifty. Past the limit, the invocation throws, and because the work ran inside waitUntil, it threw where nobody would see: no metric, no log anybody was watching. It had been like that for about a week. Every five minutes, it took the disruption rooms, the push notifications, the claims and the sweeps down with it. And it was worst during a disruption, when the disruption job had the most to do, which is the one time any of it matters.

Splitting the work by urgency

The first fix was to separate jobs by what they actually need. Each cron schedule gets its own invocation, and so its own budget.

The retention sweeps went to the nightly schedule. They delete things that are thirty days old. They never needed to run every five minutes, and running them there was borrowing from the budget of the jobs that did. Our handler checks event.cron and does only the nightly jobs when it's the nightly schedule:

async scheduled(event: ScheduledController, env: Env, ctx: ExecutionContext) {
  if (event.cron === "0 3 * * *") {
    ctx.waitUntil(runNightly(env));   // backup + retention sweeps, then return
    return;
  }
  // The five-minute tick. Cheap, always:
  ctx.waitUntil(flushStats(env));
  ctx.waitUntil(runFiveMinuteTick(env));
}

The five-minute schedule keeps the cheap, frequent things: flushing stats, the quota check, the moderation nudge.

Alternating the heavy jobs

That still left two heavy jobs on the five-minute tick: polling disruptions and running refund claims. On a busy day, each can use 30 to 50 subrequests. They don't fit together, however you slice it.

So they alternate. The tick number decides which one runs:

const tick = Math.floor(Date.now() / (5 * 60 * 1000));
const rotating = tick % 2 === 0 ? disruptions : delayClaims;
await Promise.all([
  runCronJob(entries, "quota", () => checkQuota(env)),
  rotating(),
  moderationFor(env).kickReview().catch(() => 0),
]);

Each now runs every ten minutes instead of every five. A disruption room appearing within ten minutes instead of five isn't something anybody will miss. An invocation that dies and delivers neither is.

It's a very simple trick, and it's the right answer more often than people think. Before you reach for a queue or a second Worker, ask whether a job really needs to run every tick.

Metering that doesn't break the thing it meters

Each job reports how it went: ok or failed, and for sweeps, how many rows were deleted. We wanted those numbers on the admin dashboard, so we'd notice the next silent failure. The naive version records each job's outcome as it finishes, which is a Durable Object call per job. That's eleven more subrequests, on an invocation that was already over budget.

So outcomes are collected in an array and sent in one call at the end:

async function runCronJob(entries: Entry[], name: string, fn: () => Promise<unknown>, resultMetric?: Metric) {
  try {
    const result = await fn();
    entries.push({ metric: "cron_ok", station: name, by: 1 });
    if (resultMetric && typeof result === "number" && result > 0) {
      entries.push({ metric: resultMetric, station: name, by: result });
    }
  } catch (err) {
    entries.push({ metric: "cron_failed", station: name, by: 1 });
    cronErrors.push(toErrorRecord("cron", err, `cron ${name}`));
  }
}
// … after all jobs:
if (entries.length > 0) await statsFor(env).incrMany(entries).catch(() => undefined);
await flushCronErrors(env);

The comment in our code says it well: metering eleven jobs must never be the twelfth thing that pushes the invocation over the ceiling. Errors are collected the same way and written once per tick. A healthy tick sends nothing at all.

One detail matters here: only the retention sweeps pass resultMetric. Their return value really is a count of deleted rows. Other jobs return numbers that mean something else, like profiles touched, and they mustn't show up on the dashboard as deletions.

That cron_failed counter is what we look at now. A cron that fails silently is only silent until something counts it.

A privacy promise that can't be paused

One detail in the nightly sweep is worth calling out, because it's not about performance at all.

Perron lets people log a train journey so it can tell them when they're owed a refund. A journey record says which train you boarded, from where, when, and where you got off. It's the most sensitive record in the system, and it's deleted on a fixed schedule.

Originally, that deletion lived inside the refund job, after the job checked whether the refund feature was switched on. Which meant that switching refunds off also switched off deleting journeys. Nobody intended that. It was just where the code happened to be.

The sweep now runs on its own, unconditionally, every night. A privacy promise that an operator can pause by turning off an unrelated feature isn't a promise. If your app has retention rules, check that nothing can disable them by accident, and especially not a feature flag.

The cron that ran out of CPU

The second problem came from our game Ten Million Cells. The game shows in-app prices, and we serve them from a small read-only Worker that returns a snapshot from R2. Originally, a cron on that Worker refreshed the snapshot from the App Store.

On the free plan, a cron trigger gets 10 ms of CPU, the same as any request. That's plenty for a fetch and a write. It's not plenty for fetching and reshaping a catalogue from the App Store Connect API. The Workers runtime terminated the refresh, prices expired, and the game showed stale prices until somebody noticed.

On Workers Paid, a cron gets far more CPU: 30 seconds for schedules more frequent than hourly, and 15 minutes for hourly or less often. That Worker was on the free plan, though, and moving the job turned out to be the better fix anyway. The refresh needs an App Store Connect key, and that key lives on a Mac of ours.

So the refresh moved to a once-a-minute system cron on one of our servers. It runs under flock, so two refreshes can never overlap. It reaches the Mac over SSH, makes about fifteen App Store Connect requests on a normal tick (with a hard cap of thirty), does the slow work, and writes the result to R2. The Worker went back to doing the only thing it's good at here, serving a small JSON file from the edge with a short cache. Snapshots carry an expiry, and the Worker refuses to serve one older than two minutes, so a broken refresh shows up as an error, not as quietly old prices.

There's a postscript worth knowing. When we removed the cron from the Worker, we didn't just delete the schedule. We left an empty scheduled() handler in place and set crons = [] in the config. Cloudflare can still deliver an old cron while a schedule removal propagates, and a Worker with no handler would throw on that late delivery.

The cron on staging

The third problem was the subtlest. Wrangler environments inherit triggers from the top level of the config unless you override them. Our staging Worker had no triggers key, we read that as "no crons", and Wrangler read it as "the same crons as production". So the disruption poller ran on staging every five minutes, polluting the load-test numbers that staging existed to produce.

An empty list turns them off: "triggers": { "crons": [] }. We wrote the whole story up in Wrangler environments.

Crons that should be crons

Not every story is a failure. Our booking platform Lorenly runs an uptime monitor as a Worker on a five-minute cron, and it's about the best use of the feature we can think of.

The reason it exists: no on-box monitor can report its own death. If the server is down, the server can't tell you. So a Worker on Cloudflare checks one public health endpoint every five minutes. That endpoint reports unhealthy if the database has been unreachable past a grace window, so one URL covers the host, the proxy, the app and the database. After two failures in a row, the Worker sends an SMS, which means we hear within about ten minutes. It re-alerts every hour while the outage lasts.

A Worker remembers nothing between runs, so the failure count lives in KV. And healthy means no KV record at all: KV is written only while an outage is being tracked, and cleared on recovery. Routine checks don't touch the free plan's small daily write allowance. It costs nothing to run.

What changed after Workers Paid

Perron moved to Workers Paid on 2026-10-15. On Paid, an invocation gets 10,000 subrequests by default instead of 50, and a frequent cron gets 30 seconds of CPU instead of 10 ms. The specific cliff that broke our cron is gone.

We've kept everything anyway: the split by urgency, the alternation, the single metering call, and the push fan-out, which now goes through a Cloudflare Queue so a storm of notifications never shares the cron's invocation at all. A bigger budget is a bigger number to add up. It doesn't stop you having to add it up.

Rules we follow now

Add up the cost of everything that runs in one invocation, not each job on its own. Split jobs by urgency, and give the slow, unhurried ones their own schedule. If two heavy jobs don't fit together, alternate them. Meter outcomes in one call at the end, and count failures somewhere you'll see them. Keep retention sweeps unconditional. Move a job off Workers when it needs a long-running process or a secret you'd rather keep at home. And when you remove a schedule, leave the handler in place for a while.

Most of all, assume your cron is broken until something shows you it isn't. For us, that something is a cron_failed counter on the admin dashboard, and it's the most useful number there.

For the rest of the free-plan limits that shaped these choices, see Workers free plan limits.

Start a project

Have something you want built?

Tell us about it. We reply to every message with an honest take on whether we are the right fit.

Start a project