# Compression audit

> Source: <https://webperf-snippets.net/Loading/Compression-Audit>

Finds text-based resources (JavaScript, CSS, HTML, JSON, SVG, XML and TTF/OTF fonts) served without HTTP compression, and estimates how many bytes gzip or brotli would save. Compressing text typically shrinks it by 60–80%, so an uncompressed script or stylesheet is one of the cheapest fixes for slow loading.

### Why this matters

Text compresses well, and compression happens on the server or CDN with a single configuration change. Every uncompressed kilobyte travels the network in full, which delays the resources that block rendering (CSS and scripts) and can push back First Contentful Paint and Largest Contentful Paint, most of all on slow connections.

### Snippet

```js
// Compression Audit
// Finds text-based resources served without HTTP compression and estimates the savings
// https://webperf-snippets.net

(() => {
  // @shared isOwnRequest
  // True for the Resource Timing entry of a request a snippet made with ownRequestUrl: the page
  // did not make it, so no snippet counts it.
  function isOwnRequest(entry) {
    return entry.name.endsWith("#webperf-snippets");
  }
  // @end-shared isOwnRequest

  const MIN_BYTES = 1024; // Below this, compression saves too little to matter
  const MAX_ITEMS = 50;

  // Typical size reduction gzip/brotli achieve per resource type (estimate, not a measurement)
  const SAVINGS_RATIO = { js: 0.7, css: 0.7, html: 0.7, json: 0.7, svg: 0.7, xml: 0.7, font: 0.5 };

  const EXTENSION_TYPES = {
    js: "js", mjs: "js", cjs: "js",
    css: "css",
    html: "html", htm: "html",
    json: "json", webmanifest: "json",
    svg: "svg",
    xml: "xml", rss: "xml", atom: "xml",
    ttf: "font", otf: "font", eot: "font",
  };

  // @shared formatBytes
  function formatBytes(bytes) {
    if (bytes === null || bytes === undefined || Number.isNaN(bytes)) return "-";
    if (bytes === 0) return "0 B";
    const units = ["B", "KB", "MB", "GB"];
    const i = Math.max(0, Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1));
    return (bytes / Math.pow(1024, i)).toFixed(1) + " " + units[i];
  }
  // @end-shared formatBytes

  const parseUrl = (name) => {
    try {
      return new URL(name);
    } catch {
      return null;
    }
  };

  // Text type of a resource, or null when it is not a compressible text resource
  const textType = (entry, url) => {
    if (entry.entryType === "navigation") return "html";
    const ext = url.pathname.split(".").pop().toLowerCase();
    if (EXTENSION_TYPES[ext]) return EXTENSION_TYPES[ext];
    if (entry.initiatorType === "script") return "js";
    return null;
  };

  const entries = [...performance.getEntriesByType("navigation"), ...performance.getEntriesByType("resource").filter((e) => !isOwnRequest(e))];
  const contentEncodingSupported = entries.some((e) => "contentEncoding" in e);

  const candidates = [];
  entries.forEach((entry) => {
    const url = parseUrl(entry.name);
    if (!url || !/^https?:$/.test(url.protocol)) return;
    const type = textType(entry, url);
    if (!type) return;
    candidates.push({ entry, url, type });
  });

  const compressed = [];
  const uncompressed = [];
  let sizeUnknownCount = 0;
  let skippedSmallCount = 0;
  const byEncoding = {};

  candidates.forEach(({ entry, url, type }) => {
    const decoded = entry.decodedBodySize || 0;
    const encoded = entry.encodedBodySize || 0;

    // Cross-origin resources without Timing-Allow-Origin report every size as zero
    if (decoded === 0 && url.origin !== location.origin) {
      sizeUnknownCount++;
      return;
    }
    if (decoded < MIN_BYTES) {
      skippedSmallCount++;
      return;
    }

    const encoding = (entry.contentEncoding || "").toLowerCase();
    // Without contentEncoding (older browsers), a body smaller than its decoded form was compressed
    const isCompressed = encoding ? encoding !== "identity" : encoded < decoded;

    if (isCompressed) {
      const label = encoding || "unspecified";
      byEncoding[label] = (byEncoding[label] || 0) + 1;
      compressed.push(entry);
      return;
    }

    const savings = Math.round(decoded * SAVINGS_RATIO[type]);
    uncompressed.push({
      url: entry.name,
      shortName: url.pathname.split("/").filter(Boolean).pop() || url.hostname,
      type,
      encoding: "none",
      encodedBytes: encoded,
      decodedBytes: decoded,
      estimatedSavingsBytes: savings,
    });
  });

  byEncoding.none = uncompressed.length;
  uncompressed.sort((a, b) => b.estimatedSavingsBytes - a.estimatedSavingsBytes);

  const totalUncompressedBytes = uncompressed.reduce((n, i) => n + i.decodedBytes, 0);
  const estimatedSavingsBytes = uncompressed.reduce((n, i) => n + i.estimatedSavingsBytes, 0);
  const corsLimitedAnalysis = sizeUnknownCount > 0;

  const issues = [];
  if (uncompressed.length > 0) {
    issues.push({
      severity: "warning",
      message: `${uncompressed.length} text resource(s) served without compression; enabling gzip or brotli could save about ${formatBytes(estimatedSavingsBytes)}`,
    });
  }
  if (corsLimitedAnalysis) {
    issues.push({
      severity: "info",
      message: `${sizeUnknownCount} cross-origin text resource(s) without Timing-Allow-Origin report zero sizes, so their compression is unknown`,
    });
  }
  if (!contentEncodingSupported && candidates.length > 0) {
    issues.push({
      severity: "info",
      message: "PerformanceResourceTiming.contentEncoding is not available; compression is inferred from encodedBodySize versus decodedBodySize",
    });
  }

  console.group("%c🗜️ Compression Audit", "font-weight: bold; font-size: 14px;");
  console.log(
    `Text resources: ${candidates.length} | compressed: ${compressed.length} | uncompressed: ${uncompressed.length} | size unknown: ${sizeUnknownCount}`
  );
  if (uncompressed.length > 0) {
    console.log(`Estimated savings: ~${formatBytes(estimatedSavingsBytes)} of ${formatBytes(totalUncompressedBytes)}`);
    console.table(
      uncompressed.slice(0, MAX_ITEMS).map((i) => ({
        Resource: i.shortName,
        Type: i.type,
        Size: formatBytes(i.decodedBytes),
        "Est. savings": formatBytes(i.estimatedSavingsBytes),
      }))
    );
  } else {
    console.log("%c✅ No uncompressed text resources found", "color: #22c55e;");
  }
  if (corsLimitedAnalysis) {
    console.log("%cℹ️ Some cross-origin resources could not be analyzed (no Timing-Allow-Origin)", "color: #3b82f6;");
  }
  console.groupEnd();

  return {
    script: "Compression-Audit",
    status: "ok",
    count: uncompressed.length,
    corsLimitedAnalysis,
    details: {
      totalTextResources: candidates.length,
      compressedCount: compressed.length,
      uncompressedCount: uncompressed.length,
      sizeUnknownCount,
      skippedSmallCount,
      totalUncompressedBytes,
      estimatedSavingsBytes,
      byEncoding,
      contentEncodingSupported,
    },
    items: uncompressed.slice(0, MAX_ITEMS),
    issues,
  };
})();
```

### Understanding the results

The snippet reads `contentEncoding`, `encodedBodySize` and `decodedBodySize` from the Resource Timing and Navigation Timing entries. A resource counts as compressed when it has a `contentEncoding` other than `identity`; on browsers without that property, it counts as compressed when `encodedBodySize` is smaller than `decodedBodySize`.

### Summary

| Field                            | Description                                                                                                                           |
| -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
| `count`                          | Number of text resources served uncompressed                                                                                          |
| `details.totalTextResources`     | Text resources the snippet inspected                                                                                                  |
| `details.compressedCount`        | Resources served with `gzip`, `br`, `zstd` or `deflate`                                                                               |
| `details.byEncoding`             | Resource count per encoding (`none` for uncompressed, `unspecified` when the browser hides the header but the sizes show compression) |
| `details.totalUncompressedBytes` | Decoded size of every uncompressed resource                                                                                           |
| `details.estimatedSavingsBytes`  | Estimated bytes saved by compressing them all                                                                                         |
| `items`                          | Up to 50 uncompressed resources, sorted by estimated savings                                                                          |

Resources under 1 KB are skipped, because compression saves too little to matter. They are counted in `details.skippedSmallCount`, so `compressedCount + uncompressedCount + sizeUnknownCount + skippedSmallCount` equals `totalTextResources`.

When the browser does not expose `contentEncoding` for a resource but does expose its sizes, the snippet treats it as compressed if its encoded size is smaller than its decoded size, and lists it as `unspecified` in `details.byEncoding`.

### How the savings are estimated

The estimate applies a typical reduction to the decoded size: 70% for JavaScript, CSS, HTML, JSON, SVG and XML, and 50% for TTF and OTF fonts. Real results depend on the content and the algorithm; brotli usually beats gzip. WOFF and WOFF2 fonts are already compressed, so the snippet ignores them.

### Cross-origin resources without Timing-Allow-Origin

Browsers report every size as zero for a cross-origin resource unless its server sends a `Timing-Allow-Origin` header. The snippet cannot judge those resources, so it counts them in `details.sizeUnknownCount`, leaves them out of `items`, and sets `corsLimitedAnalysis` to `true`.

> **Warning:** When `corsLimitedAnalysis` is `true`, treat the result as a lower bound.

To include a resource you control, send:

```http
Timing-Allow-Origin: *
```

### Enabling compression

| Server | Setting                                                                                              |
| ------ | ---------------------------------------------------------------------------------------------------- |
| nginx  | `gzip on;` with `gzip_types` covering JavaScript, CSS, JSON, SVG and XML, or the `ngx_brotli` module |
| Apache | `mod_deflate` (`AddOutputFilterByType DEFLATE ...`) or `mod_brotli`                                  |
| CDN    | Turn on automatic gzip or brotli compression                                                         |

Prefer brotli for static assets that you compress once at build time, and gzip or brotli at a moderate level for dynamic responses.

### Browser support

The snippet needs these features to run.

| Feature                                                                                                                           | Chrome | Edge | Firefox | Safari |
| --------------------------------------------------------------------------------------------------------------------------------- | :----: | :--: | :-----: | :----: |
| [PerformanceResourceTiming.encodedBodySize](https://developer.mozilla.org/docs/Web/API/PerformanceResourceTiming/encodedBodySize) |  ✅ 54  | ✅ 17 |   ✅ 45  | ✅ 16.4 |
| [PerformanceResourceTiming.decodedBodySize](https://developer.mozilla.org/docs/Web/API/PerformanceResourceTiming/decodedBodySize) |  ✅ 54  | ✅ 17 |   ✅ 45  | ✅ 16.4 |
| [PerformanceNavigationTiming](https://developer.mozilla.org/docs/Web/API/PerformanceNavigationTiming)                             |  ✅ 57  | ✅ 12 |   ✅ 58  |  ✅ 15  |
| All of the above                                                                                                                  |  ✅ 57  | ✅ 17 |   ✅ 58  | ✅ 16.4 |

The snippet reports on these features. It runs without them, and its advice on each one applies where it is supported.

| Feature                                                                                                                           | Chrome |  Edge | Firefox | Safari |
| --------------------------------------------------------------------------------------------------------------------------------- | :----: | :---: | :-----: | :----: |
| [PerformanceResourceTiming.contentEncoding](https://developer.mozilla.org/docs/Web/API/PerformanceResourceTiming/contentEncoding) |  ✅ 143 | ✅ 143 |    ❌    |    ❌   |

Source: [MDN browser compatibility data](https://github.com/mdn/browser-compat-data), version 8.1.4.

### Further reading

- [Optimize resource loading with compression](https://web.dev/articles/reduce-network-payloads-using-text-compression) | web.dev
- [Enable text compression](https://developer.chrome.com/docs/lighthouse/performance/uses-text-compression) | Chrome Developers
- [Content-Encoding](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Encoding) | MDN
- [Cache Strategy Analysis](https://webperf-snippets.net/Loading/Cache-Strategy-Analysis.md) | Also flags uncompressed text resources
