← Back to Blog

Your CSS and JavaScript ship uncompressed: what the sampled compression row reads

· 14 min read Your CSS and JavaScript ship uncompressed: what the sampled compression row reads

The Performance section of the Mega Analyzer has a row that reads Same-origin CSS/JS served compressed (3 sampled) when things are right and 1 of 3 sampled same-origin CSS/JS file(s) served uncompressed (main.js) when they are not. It is not measuring how big your files are. It is measuring whether your origin negotiated a content coding on the stylesheets and scripts a browser has to download to render the page, and it names the files so you can go look at exactly those. On a modern host the row is green without anyone doing anything, which is why a red one is worth a few minutes: it almost always points at a server somebody manages by hand.

What the check actually tests

While the page is being analyzed, the gap probe collects every <link rel="stylesheet"> href and every <script src> value, resolves each against the final URL, and keeps only the ones on the same origin as the page. Google Fonts and Bunny Fonts stylesheets are dropped before that filter even runs, and they would be cross-origin anyway. From what is left it takes the first two stylesheets and the first script in document order, so three files at most. The stylesheets are fetched with a GET, because the analyzer also needs their text for the font-display row. The script gets a HEAD. All three requests go through the site's fetch proxy, whose HTTP client is Node's built-in fetch, and that client asks for br, gzip, deflate on any https URL (only gzip, deflate on plain http). An origin that can compress has been asked to. The one exception: when that client throws, on an incomplete certificate chain for instance, the proxy retries with Node's plain https.request, which sends no Accept-Encoding at all. The proxy flags those replies, and the analyzer leaves any asset fetched that way out of the sample, so a broken TLS chain cannot produce a false red here; it shows up on the chain row instead.

Each response that comes back with a 2xx status stays in the sample; a 404 or a timeout drops the file rather than failing it. If the sample ends up empty, because every stylesheet is inlined or every asset lives on a CDN hostname, the row does not appear at all. When it does appear, the test is one regular expression against the Content-Encoding header of each sampled response: br, gzip or zstd anywhere in the value passes. Anything else, including no header at all, is a red fail that counts in the Performance score, and the title lists the failing files by basename with the query string stripped.

Three things do not trip it. Third-party scripts and fonts are never in the sample, so a slow analytics vendor cannot turn this row red. The HTML document itself has its own row, Content-Encoding (br/gzip/zstd compression active) under Trust / Security Headers, and is not counted here. And deflate is not in the pass pattern, so a server that still answers with Content-Encoding: deflate fails this row while it passes Lighthouse; that combination is rare enough that I have not seen it on a live audit.

The one you need to know about is the HEAD. nginx's on-the-fly gzip filter skips header-only requests: the header filter in ngx_http_gzip_filter_module.c returns early when r->header_only is set, so a HEAD against an nginx origin running gzip on comes back with no Content-Encoding even though the same URL as a GET is compressed. The gzip_static module, which serves a prebuilt .gz file from disk, sets the header before that check and answers HEAD correctly. The signature is a row that names only the script while both stylesheets pass. When you see exactly that, confirm with a GET before touching config; the fix section shows the command.

Why it matters

Here are this site's own numbers rather than a round one. The main stylesheet, style.css, is 42,908 bytes raw. Fetched from the live site with Accept-Encoding: br it is 9,163 bytes, and with Accept-Encoding: gzip it is 9,588. The shared analyzer script is 195,067 bytes raw, 46,233 under brotli and 48,531 under gzip. That is between 4 and 4.7 times the bytes for the same pixels, a 75 to 79 percent cut, inside the 60 to 80 percent range the row's own advice text quotes. Scale it to the 300 KB stylesheet a page builder emits and you are shipping 300 KB where 70 would do.

Where those bytes sit is what makes it expensive. A stylesheet in the head is render-blocking: the browser has the HTML and cannot paint until the CSS has arrived and been parsed. TCP does not send it all at once either. RFC 6928 proposed an initial congestion window of 10 segments, roughly 14 KB, and that is what a server gets to push before it has to wait for the first acknowledgment. A 9 KB compressed stylesheet fits in that first flight. The 43 KB raw version takes several round trips, and on a phone with 150 ms of latency each of those is a visible delay before first paint. The script is not on the paint path when it is deferred, but it is still four times the bytes to download and cache.

Lighthouse has flagged this for years under "Enable text compression". Its rule is close to the analyzer's: it gathers text-based responses whose content-encoding header is not br, gzip or deflate, compresses each with gzip to estimate the savings, and skips anything under 1.4 KiB or where the savings would be under 10 percent. It also states the preference plainly: "If the browser supports Brotli (br) you should use Brotli because it can reduce the file size of the resources more than the other compression algorithms." RFC 7932, which defines brotli, describes its ratio as "considerably better than the gzip program," and the live numbers above put the gap at 4 to 5 percent as Netlify serves this site's files (13 to 18 percent when I ran brotli at quality 11 against gzip level 6 locally), which is real but not the reason to act. Going from nothing to gzip is the 75 percent; going from gzip to brotli is the last few.

This is also why a fail is diagnostic. Netlify serves this site's files with Content-Encoding: br and Vary: Accept-Encoding with no configuration on my side. Cloudflare compresses eligible text at the edge with zstd, brotli or gzip depending on plan and on what the visitor's accept-encoding allows, and keeps an origin's brotli or gzip when the browser supports it. So a red row on a site behind either almost always means one of three things: a self-managed nginx, Apache or IIS with compression off or scoped to text/html only; an asset hostname that bypasses the CDN; or an origin sending cache-control: no-transform, which Cloudflare documents as the way to opt a response out of edge compression. There is a fourth that catches nginx behind any proxy: gzip_proxied defaults to off, and nginx decides a request is proxied by the presence of a Via header. A CDN that forwards Via gets uncompressed responses from an origin that compresses fine when you hit it directly, and if the CDN is set to respect the origin, visitors get them too.

How to fix it

Step 1: look at a GET, not a HEAD. curl -I sends a HEAD, and curl --compressed -I is the command everyone reaches for; on nginx it reproduces the analyzer's false reading. Ask for headers on a real GET and throw the body away:

curl -s -D- -o /dev/null -H "Accept-Encoding: br, gzip" https://example.com/css/main.css

Read Content-Encoding and Vary off the reply. Then run the same command without the -H flag: you should get no Content-Encoding and a Content-Length equal to the raw file, which proves the server is negotiating rather than serving one pre-squashed copy to everyone. If the GET is compressed and only the analyzer's HEAD sample is red, skip to the last section.

Step 2: turn it on where the bytes come from. On nginx the defaults are the trap: gzip is off, gzip_types covers text/html only, gzip_comp_level is 1, and gzip_proxied is off. Inside the http or server block:

gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 5;
gzip_min_length 1024;
gzip_types text/css application/javascript text/javascript application/json image/svg+xml text/plain application/xml font/ttf;
# with the ngx_brotli module compiled in:
brotli on;
brotli_comp_level 5;
brotli_types text/css application/javascript text/javascript application/json image/svg+xml text/plain application/xml font/ttf;
# if your build writes .br and .gz files next to each asset:
brotli_static on;
gzip_static on;

text/html does not need to be in the list; the docs say responses with that type are always compressed once gzip is on. gzip_vary on is what emits Vary: Accept-Encoding. Brotli is not in stock nginx, so it needs the ngx_brotli module built in or loaded; on a distribution package check whether brotli is a known directive before you rely on it.

On Apache, with mod_deflate and mod_brotli enabled:

<IfModule mod_brotli.c>
  AddOutputFilterByType BROTLI_COMPRESS text/html text/plain text/xml text/css text/javascript application/javascript application/json image/svg+xml
</IfModule>
<IfModule mod_deflate.c>
  AddOutputFilterByType DEFLATE text/html text/plain text/xml text/css text/javascript application/javascript application/json image/svg+xml
</IfModule>

List both: a client that sends br gets brotli, and one that only sends gzip gets DEFLATE. mod_brotli's docs state it sends Vary: Accept-Encoding on its own so a proxy caches the two representations separately, and its default BrotliCompressionQuality of 5 is documented as "a reasonable balance for dynamic content."

On IIS, compression is two Windows features plus a config element. Install Static Content Compression and Dynamic Content Compression, then enable both for the site in web.config:

<system.webServer>
  <urlCompression doStaticCompression="true" doDynamicCompression="true" />
</system.webServer>

Stock IIS only ships gzip and deflate through gzip.dll. For brotli, install Microsoft's IIS Compression package, which registers iisbrotli.dll as the br scheme in applicationHost.config and swaps gzip.dll for iiszlib.dll. Check the <httpCompression> element afterward: staticTypes and dynamicTypes need text/* and application/javascript enabled, and in Microsoft's sample the static list has application/javascript but not application/x-javascript, so a .js file mapped to the older type by an old MIME entry is served raw while the CSS, which is text/*, passes.

On Cloudflare there is nothing to turn on: the edge compresses eligible text on its own, and Compression Rules under the Rules section are the documented override when you want a different algorithm on a path or want zstd specifically. So a red row behind Cloudflare is a lookup, not a setting. Check the origin for cache-control: no-transform on the failing files, since that header opts the response out. Then check whether the asset hostname is proxied at all; a DNS-only record sends visitors straight to the origin, and the blocks above are what applies there.

Step 3: precompress at build time if you can. A dynamic filter recompresses the same bytes on every uncached request. Writing .br and .gz copies once, at quality 11 and level 9, costs nothing at request time and lets brotli_static and gzip_static serve them, which also makes HEAD responses honest. In a Node build:

import { readdirSync, readFileSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { brotliCompressSync, gzipSync, constants } from 'node:zlib';

for (const dir of ['_site/css', '_site/js']) {
  for (const name of readdirSync(dir).filter(f => /\.(css|js)$/.test(f))) {
    const file = join(dir, name);
    const raw = readFileSync(file);
    writeFileSync(file + '.br', brotliCompressSync(raw, {
      params: { [constants.BROTLI_PARAM_QUALITY]: 11 }
    }));
    writeFileSync(file + '.gz', gzipSync(raw, { level: 9 }));
  }
}

Apache's mod_brotli documentation has the equivalent rewrite block for serving .css.br and .js.br files when the client sends br, including the Header append Content-Encoding br and Vary lines those files need because no filter runs.

Step 4: keep Vary: Accept-Encoding on every compressed response. RFC 9110 says an origin "SHOULD generate a Vary header field on a cacheable response when it wishes that response to be selectively reused," and the example it gives is Vary: accept-encoding. Without it, a shared cache that stored the brotli copy may hand it to a client that only asked for gzip. nginx needs gzip_vary on; mod_brotli and mod_deflate add it; Netlify adds it at the edge, which is where this site's copy comes from.

Step 5: re-run the Mega Analyzer, then widen the sample. The Compression Codec Audit fetches up to three stylesheets and three scripts from any origin with a GET, reads the HTML document's own encoding, checks Vary, and reports whether HTTP/3 is advertised, so it is the tie-breaker when the HEAD sample looks wrong. The Asset Cache Policy Audit checks the same CSS and JS plus fonts and images for cache headers and flags uncompressed text alongside them, since the two problems tend to come from the same untouched server block. The Code-Diff Patch Generator writes unified diffs for the HTML-side findings on the page; it does not write server configuration, so the blocks above are the patch for this row.

When to leave it alone

If a GET shows Content-Encoding: gzip and only the script is named, the row is reading nginx's HEAD behavior, not your site. You can leave it red with a clear conscience, or switch that path to gzip_static with prebuilt files, which fixes the reading as a side effect and saves the CPU. Do not add brotli directives you cannot verify are loaded just to chase it.

The analyzer has no size floor. Lighthouse ignores anything under 1.4 KiB or where compression would save less than 10 percent, so a 400-byte init script served raw is technically a fail here and practically nothing. Fix it when you are in the config anyway; do not schedule work for it.

Never add gzip or brotli to formats that are already compressed. WOFF2 fonts, WebP, AVIF, JPEG and zip files are outside this row's sample, and MDN's guidance is that compressing already-compressed media "is usually not appropriate because it can increase the file size." The gzip_types list above is text only for that reason; do not widen it to */*.

cache-control: no-transform on a file you need delivered byte for byte, a signed download or a checksum-verified installer, is a deliberate choice and Cloudflare honors it. That is not a CSS or JS case, but if a build tool put the header on everything, fix the scope rather than removing it from the file that needed it.

A site with no row at all, because its CSS is inlined or its assets sit on a CDN hostname, has nothing to change here. Run the Compression Codec Audit instead, since it samples cross-origin files, and check the CDN's headers directly. And if your server answers with deflate, the analyzer fails it and Lighthouse passes it; either way, the same zlib that produces deflate produces gzip, so switch the directive and stop being the exception.

Fact-check notes and sources

  • Source: https://www.rfc-editor.org/rfc/rfc7932 (Brotli Compressed Data Format) establishes brotli as "a lossless compressed data format that compresses data using a combination of the LZ77 algorithm and Huffman coding," with a compression ratio "considerably better than the gzip program," and registers the br content coding with IANA.
  • Source: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Encoding establishes the directive values gzip, compress, deflate, br (RFC 7932) and zstd (RFC 8878), that metadata such as Content-Length refers to the encoded form, and that compressing already compressed media "is usually not appropriate because it can increase the file size."
  • Source: https://developer.chrome.com/docs/lighthouse/performance/uses-text-compression establishes that Lighthouse flags text-based responses without a content-encoding of br, gzip or deflate, skips responses under 1.4 KiB or with under 10 percent potential savings, and recommends brotli where the browser supports it.
  • Source: https://nginx.org/en/docs/http/ngx_http_gzip_module.html establishes the defaults gzip off, gzip_types text/html (with text/html always compressed), gzip_comp_level 1, gzip_min_length 20 and gzip_proxied off, that a proxied request is detected by the Via header, and that gzip_vary inserts Vary: Accept-Encoding.
  • Source: https://github.com/nginx/nginx/blob/master/src/http/modules/ngx_http_gzip_filter_module.c establishes that the gzip header filter returns early when r->header_only is set, so on-the-fly gzip is skipped for HEAD requests; ngx_http_gzip_static_module.c sets content_encoding before its own header_only check.
  • Source: https://www.rfc-editor.org/rfc/rfc9110 establishes that Content-Encoding "indicates what content codings have been applied to the representation" (8.4), that when "no Accept-Encoding header field is in the request, any content coding is considered acceptable by the user agent" (12.5.3), and that an origin "SHOULD generate a Vary header field on a cacheable response when it wishes that response to be selectively reused for subsequent requests" (12.5.5).
  • Source: https://developer.mozilla.org/en-US/docs/Web/Performance/Guides/Critical_rendering_path establishes that "CSS is render blocking: the browser blocks page rendering until it receives and processes all the CSS."
  • Source: https://www.rfc-editor.org/rfc/rfc6928 proposes raising the TCP initial window from 2 to 4 segments to 10 segments, which is about 14.6 KB at a 1,460-byte segment.
  • Source: https://httpd.apache.org/docs/2.4/mod/mod_brotli.html establishes the BROTLI_COMPRESS output filter, the AddOutputFilterByType form, the precompressed .br rewrite block, that the module sends Vary: Accept-Encoding, and the default quality of 5.
  • Source: https://learn.microsoft.com/en-us/iis/extensions/iis-compression/iis-compression-overview establishes that stock IIS ships gzip.dll for gzip and deflate, that the IIS Compression package registers iisbrotli.dll as the br scheme and replaces gzip.dll with iiszlib.dll, and the <httpCompression> element with staticTypes and dynamicTypes.
  • 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, preserves an origin's br or gzip, and that cache-control: no-transform disables edge compression for a response.
  • The byte counts are my own measurements against this site on 2026-09-21: curl with and without Accept-Encoding on /css/style.css and /js/analyzer-cross-audit.js, and Node 24's zlib for the local brotli and gzip comparisons. Node's fetch sent Accept-Encoding: br, gzip, deflate to an https echo endpoint and gzip, deflate to a local http server in the same session.

Related reading

If you are running your first site on a bare VPS because it looked cheaper than a platform, this row is one of the things the platform would have handled without asking; The $97 Launch is built around getting a small business site live on exactly those defaults, so the owner is never the one maintaining an nginx config.

This post is informational, not legal advice. Mentions of third parties are nominative fair use. No affiliation is implied.

← Back to Blog

Accessibility Options

Text Size
High Contrast
Reduce Motion
Reading Guide
Link Highlighting
Accessibility Statement

J.A. Watte is committed to ensuring digital accessibility for people with disabilities. This site conforms to WCAG 2.1 and 2.2 Level AA guidelines.

Measures Taken

  • Semantic HTML with proper heading hierarchy
  • ARIA labels and roles for interactive components
  • Color contrast ratios meeting WCAG AA (4.5:1)
  • Full keyboard navigation support
  • Skip navigation link
  • Visible focus indicators (3:1 contrast)
  • 44px minimum touch/click targets
  • Dark/light theme with system preference detection
  • Responsive design for all devices
  • Reduced motion support (CSS + toggle)
  • Text size customization (14px–20px)
  • Print stylesheet

Feedback

Contact: jwatte.com/contact

Full Accessibility StatementPrivacy Policy

Last updated: April 2026