skip to content
all writeups
5 min read updated Aug 25, 2026

Living inside the Workers subrequest budget

The music page on this site intermittently threw 500s in production while passing every local check, and the cause was not the code but Cloudflare's free-plan subrequest cap.

otjcollegecloudflareperformanceweb

The music page on this site is a set of live sections – now playing, recent tracks, three charts, a genre breakdown, stats, a listening heatmap, a rhythm clock – all fed by Last.fm. Its first version worked the way I would have written it anywhere: one server-side load awaited a Promise.all over every section’s endpoint, and each endpoint then fanned out into whatever Last.fm calls it needed. Top artists had no usable photo through Last.fm’s API – every image came back as the same placeholder hash when I tested it, a detail the docs don’t mention either way – so each artist row fetched the artist’s page as HTML, buffered it whole, and regexed out the og:image.

That version passed every check locally on Node and intermittently threw 500s in production. The stack trace pointed nowhere useful, because the cause was not in the code. It was the execution model. A Cloudflare Worker on the free plan gets 50 subrequests per invocation and 10 ms of CPU – paid plans raise both, to 10,000 subrequests and up to 5 minutes – and, by my own count logged in PLAN.md at the time, one page render was making on the order of 100 Last.fm calls, including a roughly 1 MB HTML fetch per artist, inside a single invocation.

A limit with a different shape

Every constrained environment I had worked in before constrained memory or time. Those are continuous budgets with continuous remedies: for memory, stop buffering and stream; for CPU, profile and trim the hot path. Both were live problems here – buffering those artist pages is what blew the 10 ms CPU budget first, fixed by reading a bounded 48 KB prefix up to </head> and cancelling the rest of the body.

A request count is not like that. A subrequest cannot be streamed and cannot be made cheaper; the only lever is to not make it. The fix is therefore not an optimisation of the same program but a different shape of program: cache across requests, defer work between requests, and get fan-out out of the invocation entirely. The limit also fails invisibly locally, because Node has no per-invocation request counter – nothing on a dev machine corresponds to hitting it.

Restructuring around a budget

The restructure came in three moves.

One endpoint per invocation. The page shell now server-renders with per-section skeletons and the browser does the fetching, so each section lands in its own Worker invocation with its own budget of 50. The load function hands SSR a promise that never settles – so the server pass renders every skeleton and ships immediately – and the browser pass resolves each section for real:

src/routes/music/+page.ts
const section = <T>(u: string): Promise<T | null> =>
	browser
		? fetch(u, { cache: 'no-store' })
				.then((r) => r.json() as Promise<T>)
				.catch(() => null)
		: UNRESOLVED;

Per-item lookups memoised in KV. Last.fm only exposes artwork and artist tags one item at a time, so a top-18 artist chart used to cost roughly 18 to 36 outbound requests per render. Each kind of lookup now lives in one KV key holding a whole key-to-value map: a request pays one KV read, at most budget live lookups, and – if anything needs remembering – a second read right before the write, so parallel requests don’t clobber each other. KV has no atomic read-modify-write, so the re-read narrows the race rather than closing it. The budget is explicit, down to the six-connection ceiling Cloudflare enforces per invocation:

src/lib/lastfm/memo.ts
export type MemoOptions = {
	kv?: KVNamespace;
	/** Max live lookups per request. Keeps us well under the subrequest cap. */
	budget?: number;
	/** Max simultaneous lookups. Workers allow 6 connections awaiting headers
	 *  across the whole invocation, and several endpoints share it. */
	concurrency?: number;
	hitTtlMs?: number;
	missTtlMs?: number;
};

Whatever the budget did not cover comes back as pending, which shortens that response’s cache TTL, so a cold chart converges over the next few requests instead of trying to do everything at once.

Budgets tuned to converge, though not all for the same reason. The tag budget rose from 4 to 10 on its own, nothing to do with artwork. The artwork budget rose separately, settling at 12 across all three chart kinds only once the Deezer swap made a lookup cost one request instead of up to two – which is also what gave albums a live budget at all, since Last.fm’s own cover art had never needed one. A cold 24-row mosaic now fills over about two views. The endpoint does the arithmetic where the budget is declared:

src/routes/api/lastfm/tags/+server.ts
// Artists whose tags are fetched live per request; the rest come from the KV memo.
// One `artist.getTopTags` each, so 10 live lookups = 15 subrequests worst case,
// and a cold chart of 20 artists converges over two responses.
const TAG_BUDGET = 10;

The twist worth sitting on: with the platform’s cap comfortably handled, the binding limit became the provider’s own quota. Deezer’s FAQ confirms a query quota applies but doesn’t publish the number anywhere I could read without a developer login; 50 requests per 5 seconds per IP is what testing against it showed directly. Workers egress is shared, so a burst of concurrent section loads is what actually blanked rows – confirmed by wiping the memo and firing 18 concurrent chart requests myself, which logged quota rejections and left 109 of 216 rows artless, recorded in PLAN.md at the time. Request count stayed the whole ballgame; only the owner of the number changed.

The rules are written down in PLAN.md now. The next page on this site should start inside the budget rather than be pushed into it.

Sources

Read and tested against on 25 August 2026.

  • Cloudflare Workers limits – the free-plan 50-subrequest and 10 ms CPU-time figures, the paid-plan contrast, and the six-simultaneous-connection ceiling quoted in memo.ts.
  • Workers KV: how KV works – KV’s eventual consistency and lack of a transactional read-modify-write, which is why the memo re-reads before merging a write.
  • Last.fm API: artist.getInfo – confirms Last.fm documents named image sizes with no stated pixel dimensions and no mention of a placeholder policy; the placeholder behaviour itself is what testing turned up, not what the docs say.
  • Deezer FAQs for developers – confirms a query quota exists; the exact number sits behind developers.deezer.com’s own reference pages, which require a developer login I don’t have, so 50 requests / 5 seconds per IP is this repo’s own tested figure, not a published one.
  • PLAN.md, src/lib/edge-cache.ts, src/lib/lastfm/memo.ts, src/lib/lastfm/client.ts, src/routes/api/lastfm/tags/+server.ts, src/routes/api/lastfm/top/+server.ts and src/routes/music/+page.ts in this repository, checked against the code and its git history at HEAD – every quoted code block, the budget figures and their change history, and the ~100-calls-per-render and 109/216 counts, which are my own recorded measurements from PLAN.md rather than independently re-verified for this piece.

Projects

what this writeup is about