Weak caching across a site: freshness, validators, gzip
Most pages missing a freshness lifetime, an ETag, or compression. squirrel measures the share across every crawled HTML response.
What site-wide caching coverage is
One page with weak caching headers is a nit. A site where most pages have them is a pattern, and it costs on every repeat visit and behind every CDN. A response with no explicit lifetime and no validator is not uncacheable, since a cache may fall back to a heuristic, but nothing about how long it is reused is under your control.
Three things are worth counting separately. A freshness lifetime, from max-age, s-maxage or Expires, decides whether a cache may reuse a response without asking. A validator, meaning an ETag or Last-Modified, decides whether asking is cheap: the server can answer 304 with no body. Compression decides how many bytes move when it is not cheap.
A document is often deliberately given no reusable lifetime. Cache-Control: max-age=0 or Cache-Control: no-cache beside an ETag says “ask me every time, and I will usually answer 304”, which is the right policy for a page that must never be served stale and is what most frameworks and CDNs emit for HTML. That is a caching policy, not a missing one, and it passes. Real headers usually carry must-revalidate alongside, but this check never reads that directive: the zero lifetime or the no-cache is what it keys on. What does not pass is a response offering neither: no lifetime a cache may reuse and no validator to revalidate against, so every request is a full transfer.
What squirrel checks
The rule runs once per crawl over every page whose status was 2xx, whose headers were recorded, and whose content type contains text/html. It emits one to three checks:
bad-caching, skipped with the messageNo cacheable HTML responses with headers to evaluatewhen nothing qualifies.bad-caching-freshness: reports at info status, which never moves the score. Under 60% of pages declaring a policy, the message isN/M pages set no caching policy (no freshness lifetime and no validator). Otherwise it isN/M pages declare a caching policy.bad-caching-validators: warns withN/M pages lack an ETag or Last-Modified validatorunder 60% and otherwise passes withN/M pages expose a revalidation validator. It never fails: a page with no lifetime and no validator is usually charged byperf/cache-headersalready, and a page with a lifetime only loses a cheap revalidation once it expires. When the check warns and at least half of the pages missing a validator came through Cloudflare, the message gains a note, described in When Cloudflare removes the ETag.bad-caching-compression: emitted only when at least one response was compressible, at info status, which never moves the score. Under 80% compressed, the message isN/M compressible pages served without gzip/Brotli. Otherwise it isN/M compressible pages compressed.
A page counts as declaring a caching policy in either of two ways. It has a freshness lifetime: neither no-store nor no-cache, and either a positive lifetime through s-maxage or max-age, with s-maxage taking precedence, or a non-empty Expires header. The Expires value is not parsed, so a date already in the past still counts. Or it revalidates: not no-store, a validator present, and either no-cache or a lifetime of exactly zero. A response with no Cache-Control and no Expires is neither, whatever validator it carries, because the server declared nothing and the reuse is a cache’s heuristic rather than the site’s choice. Compression counts br, gzip, deflate and zstd. Each check below its threshold carries up to five example URLs.
Any non-empty ETag is a validator, weak ones (W/"...") included, whatever the content encoding. A browser revalidating with If-None-Match compares tags weakly, so a weak tag still earns a 304. CDNs that compress a response, Cloudflare, CloudFront, Google Cloud CDN and Bunny among them, turn a strong tag weak, and that is fine.
One missing server setting costs the score once. perf/compression scores pages served without compression and perf/cache-headers scores pages with no caching headers, each page by page with its fix, and those are the findings the report lists. This rule keeps the site’s share of both in its results at info status, which neither moves the score nor appears among the report’s issues, and scores only bad-caching-validators. A site missing both settings therefore loses score for each once, not twice: a five-page test site served with neither scores 80 (B), where it scored 78 (C) when both rules charged it. Two kinds of page count as having no policy here without perf/cache-headers scoring them: one that sends no-store, which that rule treats as a deliberate choice, and one that sends max-age=0 with no validator, which bad-caching-validators scores instead once under 60% of pages have a validator.
On a local or private-network host, such as localhost or 192.168.1.10, the rule is skipped and left out of the score, because a dev server is not the production edge. See Transport and delivery rules do not apply.
How to fix it
Set the headers at the edge rather than per route, so coverage is uniform by construction:
Cache-Control: public, max-age=0, s-maxage=600, stale-while-revalidate=86400
ETag: "a1b2c3"
Content-Encoding: br
Most servers and CDNs emit an ETag automatically once the response is not streamed, and compression is usually a single switch. The freshness value is the one that needs a decision per content type.
The common false positive is a site that is entirely personalised, where every page is per-user and no-store is correct. The policy check counts those as declaring none, because from a cache’s point of view they do. The other one that looks like a false positive is an ETag your server sends but your visitors never get, because the CDN removed it. The finding is accurate for them, and the next section shows how to check.
When Cloudflare removes the ETag
Cloudflare drops the ETag from HTML it rewrites. Its docs name Email Address Obfuscation, Automatic HTTPS Rewrites, Replace insecure JavaScript libraries and JavaScript Detections, which Bot Fight Mode turns on. Some of these are on by default. On some zones the header goes missing only for requests that ask for a page, which every browser navigation does and squirrel does too, so a plain curl -I can show an ETag your visitors never receive. Compare the two:
curl -s -D - -o /dev/null -H "Accept: */*" https://example.com/page
curl -s -D - -o /dev/null -H "Accept: text/html" https://example.com/pageWhen the first prints an etag line and the second does not, a rewriting feature is the likely cause. When neither has one, fetch a file that is not HTML from the same origin, such as /robots.txt: an ETag there points the same way, though some origins only send validators for static files. To be sure, request the same page from your origin directly, bypassing Cloudflare, or turn the suspected feature off and repeat the second command.
To get a validator back, pick one:
- Add
no-transformto theCache-Controlof your HTML responses. Cloudflare skips Email Address Obfuscation, JavaScript Detections and Web Analytics injection for those responses. Keep compression on at your origin, since Cloudflare may stop compressing them for you, and add the Web Analytics snippet to your pages yourself if you rely on the automatic one. - Turn the rewriting features off for the zone, or for your HTML paths with a Configuration Rule.
- Also send
Last-Modified. None of these features is documented to remove it, and it is enough for a 304.
When bad-caching-validators warns and at least half of the pages missing a validator carry Cloudflare response headers (server: cloudflare or cf-cache-status), its message ends with this note, where K is how many of those pages came through Cloudflare:
K of them came through Cloudflare, which may drop the ETag from HTML it rewrites with Email Address Obfuscation, Automatic HTTPS Rewrites, Replace insecure JavaScript libraries or JavaScript Detections (Bot Fight Mode). Add no-transform to the HTML Cache-Control, turn those features off, or also send Last-Modified
When JavaScript Detections is found. One of those features leaves a marker in the page: an inline script that sets window.__CF$cv$params or references /cdn-cgi/challenge-platform/ (or an external script from that path). When at least half of the pages missing a validator are behind Cloudflare and carry it, the message instead says Cloudflare JavaScript Detections (turned on by Bot Fight Mode) was found on K of them, and Cloudflare removes the ETag from HTML where it injects the script, and points at the Super Bot Fight Mode switch (Security, Bots; the free Bot Fight Mode has none), no-transform, or Last-Modified; details.javascriptDetectionsPages is K. Pages behind Cloudflare without the marker keep the note above. Only a <script> counts: the path in visible text or a code sample does not, and only the first 2,048 characters of each of the last 60 scripts of a page are read (Cloudflare injects the snippet at the end of the body), so a very large page costs the same as a small one. The marker is read from the page’s own DOM on both engines (squirrel audit and squirrel analyze).
The note says “may” because squirrel only sees what Cloudflare sent, not what your origin sent: an origin that never set a validator looks the same from outside. It never changes the check’s status, and the JSON report carries the count as details.cloudflarePages.
| Rule ID | perf/bad-caching |
| Category | Performance |
| Scope | Site-wide |
| Severity | warning |
| Weight | 5/10 |
Options
| Option | Type | Default | Description |
|---|---|---|---|
min_freshness_ratio | number | 0.6 | Minimum fraction of pages that should declare a caching policy, either a freshness lifetime (Cache-Control max-age or Expires) or a validator paired with no-cache or max-age=0, before this passes |
min_validator_ratio | number | 0.6 | Minimum fraction of pages that should expose a validator (ETag or Last-Modified) for cheap revalidation |
min_compression_ratio | number | 0.8 | Minimum fraction of compressible responses that should be gzip/Brotli compressed |
Configuration example
[rule_options."perf/bad-caching"]
min_freshness_ratio = 0.6
min_validator_ratio = 0.6
min_compression_ratio = 0.8Enable / disable
Disable this rule
[rules]
disable = ["perf/bad-caching"]Disable all Performance rules
[rules]
disable = ["perf/*"]Enable only this rule
[rules]
enable = ["perf/bad-caching"]enable replaces the default ["*"], so this is the only rule selected. Leave disable out: a rule that matches any disable pattern is skipped even when enable names it. Only an enabled setting under a rule’s own [rule_options] table overrides both lists.
Related rules
- perf/cache-headers: the same headers read page by page.
- perf/compression: the per-page compression check.
- perf/asset-compression: compression on the sub-resources rather than the documents.
- perf/ttfb: the origin time this coverage is meant to stop paying repeatedly.
Performance findings ship in every audit next to the SEO, security and agent experience rules. See Core Web Vitals for how an agent works through a report.
Glossary: HTTP caching.
References
- Prevent unnecessary network requests with the HTTP Cache, web.dev
- Cache-Control, MDN
- ETag, MDN
- If-None-Match uses weak comparison, RFC 9110
- ETag headers, Cloudflare
- JavaScript detections, Cloudflare
- Replace insecure JavaScript libraries, Cloudflare
Check your site
Run squirrel audit https://example.com and open the Performance section of the report. Missing validators show up there under this rule, with example URLs; missing compression and caching headers show up under perf/compression and perf/cache-headers, page by page. Signed-out local audits need no account and cost nothing. Signed-in audits spend credits.