The Performance section of the Mega Analyzer has three rows that read the same header off different responses. HTML document revalidates on return visits looks at the page itself. Static assets carry a sane cache policy and Same-origin CSS/JS served compressed look at the first stylesheets and script the page loads. What all three are really measuring is whether the promise in Cache-Control matches what the URL can keep: a filename that never changes cannot honor a promise of a year, and a filename with a content hash in it should not be asked about on every visit. This site got the first half wrong, and the person least able to notice was me.
What the check actually tests
The HTML row reads the Cache-Control header on the document the analyzer fetched through its proxy. It pulls out the max-age number and turns amber when that number is above 3,600 seconds and the header carries none of no-cache, must-revalidate or no-store. The title prints the hours, rounded, and the first 60 characters of the header, so a day-long policy reads HTML document cached for 24 hours without revalidation (Cache-Control: public, max-age=86400). Anything else passes with the header echoed in the title. Three things do not trip it. A page with no Cache-Control header at all gets no row, which is a skip rather than a pass. An s-maxage value is never read, so a CDN-only long TTL is invisible here. And max-age=86400, must-revalidate passes, because the analyzer treats must-revalidate as a sign of intent, even though under RFC 9111 that directive only bites once the response has gone stale, so the header still serves a day-old page without asking. This is an advisory row: an amber exclamation mark, counted as a warning in the Full Summary, not a fail.
The two asset rows share one sample. While the page is being analyzed, the gap probe collects every <link rel="stylesheet"> and <script src> URL, keeps the same-origin ones, and takes the first two stylesheets and the first script in document order. The stylesheets are fetched in full, because the analyzer also needs their text for the font-display check; the script gets a HEAD request. Both go through the site's fetch proxy, which sets no Accept-Encoding of its own; Node's fetch fills in brotli, gzip and deflate on an HTTPS request, so a server that compresses for browsers should compress here too. Any file that did not come back with a 2xx status is dropped from the sample. If the page has no same-origin CSS or JS at all, either because everything is inlined or because it all comes from a CDN, neither asset row appears.
The compression row is a red fail when a sampled file's Content-Encoding header does not contain br, gzip or zstd, and the title names the files: 2 of 3 sampled same-origin CSS/JS file(s) served uncompressed (main.css, app.js). A deflate encoding would count as uncompressed, which is rare enough not to matter. What does matter is the HEAD request on the script. A server that leaves Content-Encoding off HEAD responses can turn that one sample red while a real GET is compressed, so confirm with a GET before you change anything.
The policy row decides for each sampled file whether its URL is versioned. Three shapes count: a run of six or more hex characters set off by a dot or hyphen right before .css or .js, as in main.3f9a1c.css; a query key named v, ver, version, h or hash; or a /v2/ style path segment. An unversioned file served immutable, or with a max-age of 2,592,000 seconds (30 days) or more, is a mismatch and prints as Static asset cache policy mismatch: main.css is unversioned but cached immutable, or "cached 365 days" when there is no immutable token. A versioned file with a max-age under 86,400 is the other mismatch and prints as "app.8c1d2e.js is fingerprinted but cached only 0s". A versioned file with no max-age at all is not flagged, and the title lists at most three problems. This row is amber too. One honest limit: base64-style bundler hashes like index-BxK3p9Qz.js contain letters outside a to f, so they do not match the hex pattern, and a correctly immutable bundle from a build that emits them can show as unversioned here. The Asset Cache Policy Audit has a wider version detector and is the tie-breaker.
Why it matters
RFC 9111 defines max-age as the number of seconds after which "the response is to be considered stale", and until then a cache may reuse it without contacting the origin. The immutable extension in RFC 8246 goes one step further: "Clients SHOULD NOT issue a conditional request during the response's freshness lifetime (e.g., upon a reload) unless explicitly overridden by the user." Put those together on a stylesheet at /css/site.css served with max-age=31536000, immutable and you have made a promise the URL cannot keep. A browser that follows the RFC will not ask about that file again for a year, not on a new session and not on a normal reload. Deploy a layout change and every returning visitor keeps the old layout until the year runs out, they force a reload, or they clear their cache.
The cruel part is who sees the bug. This site's netlify.toml served tool-shell.css and analyzer-cross-audit.js as immutable for a year, and both files are edited in place on every deploy. A returning visitor kept the old CSS and the old analyzer JavaScript until 2027, while I arrived with a cold cache, saw the fix, and concluded it had shipped. A small-business site I maintain showed me the same thing from the other side: a new footer row was deployed and confirmed byte for byte in the served stylesheet with curl, and in a browser that had visited the site earlier that day it still rendered as unstyled list bullets with icons 316 pixels tall. The server was right. The browser never asked it.
Your CDN does not rescue you from this. Netlify's documentation says every new deploy invalidates the edge cache for that deploy context, and that is true, but it is the CDN's copy that gets invalidated. Nothing you do on the server reaches into a visitor's browser cache; the only thing governing it is the header that browser received last time.
The opposite mistake is cheaper but real. Netlify's default header on static assets is Cache-Control: public, max-age=0, must-revalidate, which is the right default for unversioned files: stale on arrival, and not reusable until validated, so a return visit is a conditional request and usually a 304 with no body. On a hashed bundle that can never change, that is one round trip per file that could have been zero, and for a render-blocking stylesheet the round trip sits in front of first paint. It is also why a fingerprinted build on Netlify with no headers file earns the "cached only 0s" warning from this row.
Compression is the simplest of the three and the one with the biggest byte count. I ran this site's own files through Node's zlib at gzip level 6 and brotli quality 5. The shared tool stylesheet is 17.4 KB raw, 4.9 KB under gzip and 4.6 KB under brotli. The cross-audit script is 206.5 KB raw, 50.2 KB under gzip and 46.2 KB under brotli. Serving those uncompressed is three and a half to four and a half times the bytes for the same pixels, on every cold visit. Leaving Cache-Control off entirely is not neutral either: web.dev's guide puts it plainly, "Leaving out the Cache-Control response header does not disable HTTP caching!", because browsers fall back to a heuristic guess.
How to fix it
Step 1: sort your files into two classes. A fingerprinted file is one whose URL changes whenever its bytes change, which is what Vite, webpack, Next.js and most site generators emit when told to. An unversioned file is style.css or app.js edited in place. The long-term fix for class two is to move it into class one at build time. On a plain Node build the whole trick is a hash in the filename:
import { createHash } from 'node:crypto';
import { readFileSync } from 'node:fs';
const hash8 = s => createHash('sha256').update(s).digest('hex').slice(0, 8);
const css = readFileSync('src/css/site.css');
const cssFile = 'site.' + hash8(css) + '.css'; // site.e0aacec1.css
Compute it before any page that embeds the URL is generated. On the site above, the page loop ran before the stylesheet was written, and the constants had to move up.
Step 2: give each class its header. On Netlify, a _headers file at the publish root:
/assets/*
Cache-Control: public, max-age=31536000, immutable
/css/*
Cache-Control: public, max-age=600, stale-while-revalidate=86400
/js/*
Cache-Control: public, max-age=600, stale-while-revalidate=86400
The first block is for hashed paths. The other two are for files you have not fingerprinted yet, and they carry the policy this site runs today: ten minutes fresh, then a day in which the cached copy is served while the browser refreshes it in the background, so repeat views stay instant without a promise the filenames cannot keep. HTML needs no rule on Netlify, since the platform default already sends max-age=0, must-revalidate; on another host add a /*.html block with that value. The same rules go in netlify.toml as [[headers]] entries with for = "/assets/*" if you prefer one config file.
On Cloudflare the equivalent lives in Cache Rules, and the Asset Cache Policy Audit prints them in the shape the dashboard uses. A rule named "Fingerprinted assets" matching URI Path starts with /assets/, cache eligibility Eligible for cache, Edge TTL 1 year, Browser TTL Override 1 year, with the origin also sending max-age=31536000, immutable so the header survives if the rule is ever removed. A rule named "Unversioned assets" with Browser TTL set to Respect origin, and the origin sending max-age=0, must-revalidate. A rule named "HTML" matching paths that end in / or .html, again Respect origin, and a zone purge on deploy.
On nginx, inside the server block:
location ^~ /assets/ {
expires 1y;
add_header Cache-Control "public, max-age=31536000, immutable";
}
location ~* \.(css|js)$ {
add_header Cache-Control "public, max-age=0, must-revalidate";
etag on;
}
location ~* \.html$ {
add_header Cache-Control "public, max-age=0, must-revalidate";
}
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_types text/css application/javascript text/javascript application/json image/svg+xml text/plain application/xml;
# with ngx_brotli: brotli on; brotli_types <same list>;
expires 1y sets the Expires header and a Cache-Control: max-age=31536000 for older caches; the add_header line supplies the immutable token. This is the part most nginx configs get wrong: the docs state that add_header directives are inherited from the previous level "if and only if there are no add_header directives defined on the current level". The moment a location block adds its own Cache-Control, every add_header from the server block, including your security headers, stops applying inside it. Repeat them in the block or pull them in with include.
On Apache, in the vhost or .htaccess, with mod_expires, mod_headers and mod_deflate or mod_brotli enabled:
<IfModule mod_expires.c>
ExpiresActive On
ExpiresByType text/html "access plus 0 seconds"
</IfModule>
<IfModule mod_headers.c>
<If "%{REQUEST_URI} =~ m#^/assets/#">
Header set Cache-Control "public, max-age=31536000, immutable"
</If>
</IfModule>
<FilesMatch "\.html$">
Header set Cache-Control "public, max-age=0, must-revalidate"
</FilesMatch>
<IfModule mod_deflate.c>
AddOutputFilterByType DEFLATE text/html text/css application/javascript text/javascript application/json image/svg+xml text/plain application/xml
</IfModule>
<IfModule mod_brotli.c>
AddOutputFilterByType BROTLI_COMPRESS text/html text/css application/javascript text/javascript application/json image/svg+xml text/plain application/xml
</IfModule>
Header append Vary Accept-Encoding
Step 3: turn compression on where you do not control the server. Cloudflare compresses eligible text responses at the edge on its own, with zstd, brotli or gzip depending on plan and on what the visitor's Accept-Encoding allows, and it keeps a brotli or gzip encoding the origin already applied. This site sits on Netlify and serves Content-Encoding: br without any configuration of mine. The nginx and Apache blocks above are the self-hosted version. Whatever the host, confirm it with one request that advertises what a browser advertises:
curl -sI -H "Accept-Encoding: br, gzip" https://example.com/css/site.css
Read Cache-Control, Content-Encoding, ETag and Vary off the reply. This site's stylesheet answers public, max-age=600, stale-while-revalidate=86400, br, an ETag, and Vary: Accept-Encoding, which is exactly the middle-class policy from Step 2.
Step 4: re-run the Mega Analyzer and watch all three rows, then widen the sample. The Asset Cache Policy Audit checks CSS, JS, fonts and images on one page and prints the Netlify, Cloudflare, nginx and Apache snippets pre-filled with the paths it found. The Edge Cache Effectiveness Probe takes a list of URLs, reads Cache-Status, CF-Cache-Status, X-Cache and Age, reports the hit ratio per host, and separately fails any unversioned URL frozen as immutable. The Compression Codec Audit covers brotli against gzip against zstd on every text response plus HTTP/3 advertisement. The Code-Diff Patch Generator writes unified diffs for the HTML-side findings from the same page, such as the tag changes that come with renaming files; it does not write server configuration, so the header work stays with the snippets above.
When to leave it alone
If the policy row is green because your unversioned files carry Netlify's default max-age=0, must-revalidate, leave it green. Adding stale-while-revalidate makes repeat views faster; adding immutable makes the row amber and your next deploy invisible. Never fix a passing row by making a longer promise.
A build that emits base64-style hashes and serves them immutable can draw an amber mismatch it does not deserve. Run the Asset Cache Policy Audit; if it reports a bundler hash in the filename, keep the year and accept the amber row. Do not bolt a ?v= query onto a URL that already changes with its content just to satisfy a regular expression.
Personalized HTML belongs under no-store or private, and the HTML row accepts no-store. A public page that can tolerate an hour of staleness passes with max-age=3600 on its own; that is a choice, not a defect, as long as you made it on purpose and know that price changes will lag by up to an hour.
Fonts and images are not in this row's sample, and they do not all need a year. This site's images are content-addressed by convention rather than by hash, since a new post brings a new filename, so they get a week of freshness with a month of background refresh. A regenerated hero under an existing slug would otherwise be pinned for a year for everyone who had already seen it.
Third-party stylesheets and scripts are never sampled, and their headers are not yours to set. And if the compression row names only the script while your stylesheets pass, check a GET before touching config; the HEAD sample is the one most likely to be wrong.
Fact-check notes and sources
- Source: https://www.rfc-editor.org/rfc/rfc9111 establishes
max-age("the response is to be considered stale after its age is greater than the specified number of seconds", section 5.2.2.1),must-revalidate("once the response has become stale, a cache MUST NOT reuse that response to satisfy another request until it has been successfully validated by the origin", 5.2.2.2),no-cache(5.2.2.4) andno-store(5.2.2.5), and heuristic freshness when no explicit expiration is given (4.2). Neitherimmutablenorstale-while-revalidateis defined in this RFC. - Source: https://www.rfc-editor.org/rfc/rfc8246 (HTTP Immutable Responses) establishes that
immutable"indicates that the origin server will not update the representation of that resource during the freshness lifetime of the response", that clients "SHOULD NOT issue a conditional request during the response's freshness lifetime (e.g., upon a reload)", and that the extension "only applies during the freshness lifetime of the stored response". - Source: https://www.rfc-editor.org/rfc/rfc5861 (HTTP Cache-Control Extensions for Stale Content) defines
stale-while-revalidateandstale-if-error. - Source: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control establishes the cache-busting pattern (
Cache-Control: max-age=31536000, immutableon hashed assets,Cache-Control: no-cacheon the HTML), that "no-cache does not mean 'don't cache'" but requires revalidation before reuse, and thatimmutableexists to avoid conditional requests on reload. - Source: https://web.dev/articles/http-cache establishes
max-age=31536000for fingerprinted URLs,no-cachefor unversioned ones, and that "Leaving out the Cache-Control response header does not disable HTTP caching!" - Source: https://docs.netlify.com/routing/headers/ establishes the
_headersfile syntax (a path line followed by indentedHeader: valuelines), the[[headers]]form innetlify.toml, and that a*wildcard can appear anywhere within a path segment. - Source: https://docs.netlify.com/platform/caching/ establishes Netlify's default
Cache-Control: public, max-age=0, must-revalidateon static assets and that "all new deploys invalidate the cache for the given deploy context by default". - Source: https://developers.cloudflare.com/speed/optimization/content/compression/ establishes that Cloudflare compresses with gzip, brotli or zstd according to the visitor's
accept-encoding, plan and content type, and keeps an origin's brotli or gzip encoding when the browser supports it. - Source: https://developers.cloudflare.com/cache/how-to/cache-rules/settings/ establishes the Cache Rules settings named above: cache eligibility, Edge TTL, and Browser TTL with Bypass cache, Respect origin and Override origin.
- Source: https://nginx.org/en/docs/http/ngx_http_headers_module.html establishes that
expireswith a positive time emitsCache-Control: max-age=t, and theadd_headerinheritance rule quoted above. - Source: https://httpd.apache.org/docs/2.4/mod/mod_expires.html establishes that mod_expires "controls the setting of the Expires HTTP header and the max-age directive of the Cache-Control HTTP header".
- The compression figures are my own measurements on this site's source files with Node 24's zlib (gzip level 6, brotli quality 5), the live header values are from a curl against this site on 2026-09-21, and the proxy's
Accept-Encodingwas read back from a request-header echo endpoint the same day.
Related reading
- Why Edge Cache Effectiveness Probe Exists
- Why Compression Codec Audit Exists
- Auditing Core Web Vitals: a client-side approach without the PSI quota
- GPTBot doesn't run JavaScript: what your site looks like to it
If you run more than one site, the headers file is the thing to standardize first, because it is the same three blocks on every property and the bug it prevents is the one nobody reports. The $100 Network treats that kind of shared configuration as the base layer a multi-site operator builds on.
This post is informational, not legal advice. Mentions of third parties are nominative fair use. No affiliation is implied.