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 serves no real images for artists or tracks – only its placeholder star – and its album covers cap at 300². 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” returns a 27-fan entry ahead of the 14.5M-fan one), 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; the miss was a rendition suffix, recovered by a conditional retry on the stripped title.
The swap also moved the binding limit: Deezer allows 50 requests per 5 seconds per IP and Workers egress is shared, so bursts rather than any platform cap became what to budget against. Depending on an API you do not control means its quota is your quota.
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, so storing at the fresh TTL would leave nothing to serve stale – entries are stored with the whole SWR window 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, 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.
Projects
what this writeup is about