Findings · 2026-08-20 · By VSNARY | Emmanuel Orta
Cache your misses too: a lookup that only caches on success re-pays forever
A cache that only records successes speeds up the requests that were already fast and does nothing for the ones that hurt.
Scanning the same domain three times in a row, no code change between them, gave three different answers: 36.8s, 20.6s, 12.9s. A single reading would have said “about twenty seconds” and sent us looking for something twenty seconds long. There isn't anything twenty seconds long. The number was an average of a fast path and a slow path, and averaging them hid both.
Time the stage, not the request
Comparing the three runs stage by stage put every bit of the variance in three places:
stage min max spread local 105 8000 7895 vitals 710 8240 7530 tail_resolveDeclaredCitations 967 7000 6033 __scan_call 3333 3858 525 tail_rememberScan 1851 2349 498
Everything below the top three is stable to within half a second. The top three swing by seven or eight seconds each.
The part that was actually wrong
vitals asks the Chrome UX Report whether Google holds real-world performance data for the site. For a local contractor the answer is no, and it always will be — CrUX only publishes an origin once it has enough Chrome visits, and a regional business does not get them.
So the honest reading: the scan was spending up to 8.2 seconds to learn nothing. That is fine once. It is not fine every time. It was every time, and the reason is a shape worth recognising:
async function cruxVitals(env, host, path) {
const c = await env.REPORTS.get(ck, "json"); // cache READ, at the top
if (c && c.scope !== undefined) return c;
let j = await cruxQuery(key, { url: url });
if (j && j.error) j = await cruxQuery(key, { origin: host });
if (!j || j.error || !j.record) {
if (er) return await psiIntoVitals(env, out, url);
return out; // ← returns HERE
}
// ... extract metrics ...
await env.REPORTS.put(ck, ..., { expirationTtl: 86400 }); // cache WRITE, at the bottom
}The cache is read at the top and written at the bottom. Every path that fails returns before it reaches the write.
The consequence is exactly backwards. A successful lookup — already the fast case, since the data existed and came back — is cached for 24 hours and is nearly free from then on. A failed lookup pays three uncached round trips: the URL query, the origin fallback after it errors, then a PageSpeed attempt, because NOT_FOUND is a truthy error string. None of those three results is stored. The slow path stays slow, on every scan, permanently.
Why “cache the miss” is not the whole rule
The tempting fix is to move the write to the end of every path. That is too blunt, because the failures are not the same kind of fact.
NOT_FOUNDis a fact about the site. CrUX has no data for this origin, and that answer is stable for weeks. Cache it, and cache it as long as you would a hit.- A refused or invalid key is a fact about us. If our own credential is wrong, caching that answer means we keep reporting a broken configuration for 24 hours after somebody fixes it. Never cache it. Retry.
Same function, same return, two different lifetimes — because one describes the thing being measured and the other describes the instrument.
We then broke our own rule
We shipped the fix, and it cached a third thing we had not thought about: a transient psi_state: "pending". That is neither a fact about the site nor a bad credential — it is our request not having finished yet. Cached for 24 hours, it froze the pending state and stopped the retry from ever running again. An intermittent failure became a permanent one, and it stayed that way for six hours.
The guard now excludes pending and unreached alongside the credential errors. The general rule, stated properly: cache facts about the thing you measured; never cache facts about your own attempt to measure it.
The result
After the fix, the vitals stage went from up to 8,240ms to 3ms once warm. The measurement did not change. We just stopped paying for the same empty answer on every scan.
Three things that transfer
- Time the stage, not the request. A total duration cannot tell you whether you have one slow thing or one occasionally-slow thing, and those need opposite fixes.
- Check which branches reach your cache write. If it sits after the happy path, your cache is only accelerating the case that did not need it.
- Classify your failures before caching them. “The answer is no” and “we could not ask” look identical in a return value and must never share a TTL.
Every figure above came out of this scanner.
Point it at your own domain and see the same measurements, free.