Loading
Compression Audit

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

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

(() => {
  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")];
  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

FieldDescription
countNumber of text resources served uncompressed
details.totalTextResourcesText resources the snippet inspected
details.compressedCountResources served with gzip, br, zstd or deflate
details.byEncodingResource count per encoding (none for uncompressed, unspecified when the browser hides the header but the sizes show compression)
details.totalUncompressedBytesDecoded size of every uncompressed resource
details.estimatedSavingsBytesEstimated bytes saved by compressing them all
itemsUp 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.

⚠️

When corsLimitedAnalysis is true, treat the result as a lower bound.

To include a resource you control, send:

Timing-Allow-Origin: *

Enabling compression

ServerSetting
nginxgzip on; with gzip_types covering JavaScript, CSS, JSON, SVG and XML, or the ngx_brotli module
Apachemod_deflate (AddOutputFilterByType DEFLATE ...) or mod_brotli
CDNTurn 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.

Further reading