CrawlCheck

Findings · 2026-08-20 · By

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.

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

  1. 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.
  2. 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.
  3. 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.

Scan a domain — free

Related findings

All findings · The dataset · How the dataset works