Search nomadLab

Ten Milliseconds of CPU: I Broke the Cron by Making It Run More Often

The Workers free plan gives each invocation 10 ms of CPU, and an isolate tolerates going over as long as you do not do it often. I changed a cron from every ten minutes to every minute and it died an hour later. Then the profiler disagreed with all three of my guesses.

Hot Deal Sheet collects deal posts from Korean forums on a schedule and serves them as pages. It runs on the Cloudflare Workers free plan, and staying inside that plan is most of the design. On 19 August 2026 I changed the collection cron from every ten minutes to every minute, because fresher is better. An hour later every run was ending in Exceeded CPU Limit, and I did not find out for about two hours.

Nothing about the work per run had changed. Each run still fetched one forum, parsed it, and wrote the changed rows. The only thing I touched was how often it happened.

The free plan allows 10 ms of CPU per invocation, and that applies to cron triggers exactly as it applies to HTTP requests. Waiting on the network does not count, so fetch and D1 queries are free. Parsing, hashing, and rendering are not. On the paid plan the same number is 30 seconds by default. Cloudflare’s own docs say the average Worker uses about 2.2 ms, and that “heavier workloads that handle authentication, server-side rendering, or parse large payloads typically use 10-20 ms.” Read that twice if you are serving rendered HTML from the free plan. The band the vendor calls normal for server-side rendering starts above the free limit.

The sentence that explains the two-hour delay is one paragraph further down the same page: “Each isolate has some built-in flexibility to allow for cases where your Worker infrequently runs over the configured limit. If your Worker starts hitting the limit consistently, its execution will be terminated.” Going over is not an error. Going over often is. So a Worker can sit above the limit for weeks, look healthy, and then fall over on the day you multiply its call frequency by ten. Limits below came off the Cloudflare Workers limits and pricing docs on 23 August 2026.

You cannot measure this from inside the Worker

My first instinct was to wrap the collector in a timer. That returns zero, every time, and the reason is deliberate. In Workers, Date.now() returns the time of the last I/O and does not advance during code execution. Cloudflare shipped that in mid-2017 to close side-channel timing attacks. Two reads around a synchronous loop give you the same value.

So you measure from outside:

npx wrangler tail hotdealsheet-cron --format json

Every event carries cpuTime in milliseconds and an outcome. When you are over, the outcome is exceededCpu and the client gets error 1102. For page requests you have to add a cache-busting query string, otherwise the edge answers and the Worker never runs at all.

Every page with a table starts at 14 ms

Once I could see the number, I measured every route. Six samples each, minimum taken, on 20 August 2026.

Minimum CPU time per invocation on the Cloudflare Workers free plan, by route. Legal pages 1 millisecond, the notice page 2, the date calendar 5, a date page with no deals 5, one cron run 6, the card board with 62 cards 8. All of those are under the 10 millisecond free limit. The home page with 100 table rows is 20 milliseconds, the under-10000 listing with 100 rows is 42, and a date page with 266 rows is 97. Every route that renders the deal table is over the limit. Minimum CPU per invocation. Every page with a table crosses the 10 ms line. 10 ms, free plan legal pages1 ms notice2 ms date calendar5 ms date page, no deals5 ms cron, one source6 ms board, 62 cards8 ms home, 100 rows20 ms under-10000, 100 rows42 ms date page, 266 rows97 ms Minimum of six samples per route, read from wrangler tail on 20 August 2026.
The cron was never the expensive part. It was the only part running often enough to get caught.

Varying the row count across eleven date pages gives a straight line:

CPU for a page with a table ≈ 14 ms + 0.21 ms × rows

The slope is the boring half. The intercept is the finding. A table with a single row costs 14 ms, which means no amount of trimming rows gets that page under 10. Pages without a table are all comfortably inside: legal documents 1 to 4 ms, the notice page 2, the date calendar 5, a date page with zero deals 5. The cliff sits between “renders a table” and “does not,” not between big tables and small ones.

The card board is the check on that. The same deals drawn as 62 cards cost 8 to 13 ms, cheaper than 100 table rows at 20 to 45. A card is 283 to 519 bytes and a table row is 880, both measured. Cost tracks output bytes, which leaves exactly two levers: how many rows, and how many bytes each row costs.

Three guesses, three wrong

I had written all three into the project notes as fact before measuring any of them.

The first was that the heavy forum was the expensive one to collect. It is not close. A 128 KB page with 25 deals took 8 ms and a 14 KB page with 20 deals took 6. Parsing barely registers. What dominates is normalization, one SHA-256 per deal, plus building the SQL statements. The number to watch is deal count, not payload size.

The second was that the home page was the worst route. The worst route is a date page from a busy day, at five times the home page.

The third was the expensive one. I was sure the React island doing table rendering on the server was the body of the cost, because it is the only React on the site. Profiling put server-side React at 0.7% of request CPU. The actual weight was Astro concatenating the table HTML into strings, stringToChunk and writeChunk together at 32 to 43%, plus the garbage collection that follows at 10 to 12%. Ripping out the island would have been a week of work for nothing.

Getting that profile needs a small detour, because the DevTools inspector wants a keypress in the terminal and does not automate. Start wrangler dev with --inspector-port 9230, attach to ws://localhost:9230/ws over CDP, then Profiler.enable, Profiler.setSamplingInterval, Profiler.start, fire 20 to 30 requests, Profiler.stop, and aggregate self time from the returned nodes and samples.

Do not substitute a local Node benchmark for this. toLocaleString costs 0.27 µs in Node and 6 µs in workerd. Same function, different runtime, and that gap was the whole basis of one optimization.

What keeps the site alive is the cache

Every table route on this site is structurally over the free limit, and the site is fine. The reason is that cache hits count as requests but consume no CPU. A middleware file sets an explicit Cache-Control per path, so the home page renders once per point of presence per five minutes and a past date page renders once a day. Frequency stays low, the isolate stays forgiving.

Which is the same mechanism that killed the cron, pointed the other way. That makes the real budget a line in src/middleware.ts, not a row limit in a query. Anything that lowers s-maxage spends CPU allowance directly, and nothing in the dashboard will tell you that.

When a route does start getting caught, the options run cheapest to most expensive. Raise the cache window, which fixes the symptom and leaves the cause. Cap rows, which helps the worst page but breaks the internal links that page exists to provide. Cut bytes per row, where the wins are dull and real: one icon repeated on every row was 22% of the list HTML, and moving it to a <symbol> and <use> took 24% off the page. Or pay the $5 and get 30 seconds instead of 10 ms.

One warning about the measuring itself. The median for a single route swung between 41 and 188 ms depending on which point of presence and how contended the isolate was. Three or four samples support no conclusion at all. The minimum is the cleanest estimate you can get cheaply; if you want a median, collect about thirty per route.

The part I still think about is the two hours. created_at only changes on insert, so a collector that is dead looks identical to a quiet night with no new deals. The signal that actually distinguishes them is sources.last_fetched_at, which now gets exposed on a status endpoint that answers 503 when any source has been silent for more than fifteen minutes. A free uptime monitor points at it. That is the piece I should have built before I touched the cron schedule, not after.

Keep reading