Stale-while-revalidate with APIs you do not control
Why chart artwork moved from Last.fm to Deezer, serving stale entries while their refresh queues, and versioning a cache key when a deploy changes what an endpoint means.
The music page on this site depends on Last.fm for its data and, until recently, its artwork. Neither comes with a contract: no SLA, no deprecation policy, no promise a response shape survives the month. Deezer, brought in partway through, is keyless and documented only by observation. The design treats every upstream as fallible and the cache as the one component I own. Three decisions carry it.
Artwork, off the fragile path
Last.fm’s API documents named image sizes for artists, albums and tracks but never states their pixel dimensions, and says nothing about a placeholder policy. What testing against it actually showed: every artist and track image came back as the same hash, a small grey star Last.fm serves in place of a real photo, and album art, while real, never rendered above roughly 300 pixels square for this profile. The workaround scraped each artist’s page for its og:image, falling back to the cover of their top album. It was the most fragile call on the page: dependent on Last.fm’s markup, not its API, and it worked until it silently did not.
Resolution moved to Deezer’s keyless search – one provider at 500² for all three chart kinds, so they look consistent. Whatever Last.fm did supply stays as the fallback for a Deezer miss, so the switch can never leave a row emptier than before. Deezer needed its own care: limit=1 is wrong because its ranking floats junk duplicates above the canonical entity (searching “The Weeknd” returned a 27-fan entry ahead of the 14.5M-fan one when I checked), so candidates are scored; and one forgiving "{artist} {title}" query beat a chain of field-scoped ones, which returned nothing whenever Deezer spelled a title differently. That got 95 of 96 lookups in testing; the miss was a rendition suffix, recovered by a conditional retry on the stripped title.
The swap also moved the binding limit: Workers egress is shared, and Deezer enforces a query quota whose exact number sits behind a developer login I don’t have – 50 requests per 5 seconds per IP is what testing against it directly showed, and it’s what the code budgets against. Depending on an API you do not control means its quota is your quota, published or not.
Stale is a decision, not a state
Every endpoint goes through one stale-while-revalidate responder: serve fresh within freshSec; within the wider swrSec window, serve the lapsed entry immediately and refresh it behind the response; if producing fails, serve a brief fallback rather than hammering the upstream. The serve-stale path is the second branch:
if (ageSec < freshSec) return reheat(hit, freshSec);
if (ageSec < cfg.swrSec && ctx?.waitUntil) {
ctx.waitUntil(
(async () => {
try {
const data = await produce();
await respond(data, freshFor(data), cfg.swrSec);
} catch (err) {
// Keep the stale entry; the next request retries. Logged, not silent -
// a background revalidation that never once succeeds would otherwise
// look identical to one that's merely waiting its turn.
console.error(`[${cfg.label}] background revalidate failed:`, err);
}
})()
);
return reheat(hit, freshSec);
}Two Cloudflare behaviours shaped the implementation. caches.default stops returning an entry once its stored max-age lapses – an expired entry behaves as a miss, not as data with a flag on it – so storing at the fresh TTL would leave nothing to serve stale; entries are stored with the whole SWR window instead, and freshness rides on x-cached-at / x-fresh-sec headers. And the SvelteKit Cloudflare adapter wraps the worker in its own cache layer matching the bare request URL, checked before SvelteKit itself ever runs, so the long-lived copy lives under a key marked __swr the adapter can never match. The same decision appears one level down in the KV memo: a lapsed entry keeps serving its value while its refresh is queued, because dropping it would blank the row until its turn comes in the budget.
When the meaning changes, the key has to change
The genuinely hard invalidation problem is not time. Entries are keyed by URL, and a deploy can change what an endpoint means – its shape, or the aggregation behind it – while the URL, and therefore the key, stays identical. The stale entry is fresh by its own clock; a TTL is no protection; the cache keeps handing out the old meaning for its whole window. So the cache config takes a version, folded into the key itself:
const keyUrl = new URL(event.request.url);
keyUrl.searchParams.set(SWR_KEY_MARKER, '1');
if (cfg.version !== undefined) keyUrl.searchParams.set(VERSION_MARKER, String(cfg.version));
const key = new Request(keyUrl, { method: 'GET' });Bumping version makes the deploy its own invalidation: old entries are not deleted but unreachable, and age out on their own while the new generation starts cold. Three bumps are in the tree, each with its reason in a comment. The clearest is the history endpoint, whose loops payload widened from a top-8 slice to every stored run:
label: 'lastfm',
// 2: `loops` carries every stored run instead of a top-8 slice, so older
// entries can't fill the section's top 10 or its "see all" listing.
version: 2The tags endpoint is on version 3: equivalent spellings merged (kpop folds into k-pop), then plays split across tags rather than counted once per tag – every weight, so every percentage, differs under identical URLs. The KV memo made the same move, generation in the map key: artwork maps are on art:*:v3, because entries from the page-scraping v1 and field-scoped-chain v2 eras describe a different thing.
What I took from it: a TTL answers “is this entry old enough to recheck”. It has nothing to say about “was this entry produced by a version of the code that no longer means this”. For a derived cache, the version of the producer is the honest unit of invalidation, and the key is where it belongs.
Sources
Read and tested against on 25 August 2026.
- Cloudflare Workers Cache API – confirms
cache.match()treats a lapsed entry as a miss rather than as flagged stale data, which is why freshness has to be tracked outside the storedmax-age. @sveltejs/adapter-cloudflaresource, v7.2.8 – the exact version installed in this repo; confirms the adapter checkscaches.defaultagainst the bare incoming request before SvelteKit runs, and stores cacheable responses back under that same bare request afterwards.- Last.fm API: artist.getInfo and album.getInfo – confirm Last.fm documents named image sizes with no stated pixel dimensions and no placeholder policy; the placeholder hash and the 300px ceiling are what testing against the live API found, not what the docs assert.
- Deezer FAQs for developers – confirms a query quota exists on the API; the exact number is on
developers.deezer.com’s 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. The live search endpoint needs no key, matching the keyless claim above. src/lib/edge-cache.ts,src/lib/deezer/art.ts,src/lib/lastfm/client.ts,src/routes/api/lastfm/history/+server.tsandsrc/routes/api/lastfm/tags/+server.tsin this repository, checked against the code and its git history at HEAD – every quoted code block, the three cache-key version bumps, and the 95/96 Deezer match rate, which is a figure from this repo’s own testing rather than independently re-verified for this piece.
Projects
what this writeup is about