# Cache strategy analysis

> Source: <https://webperf-snippets.net/Loading/Cache-Strategy-Analysis>

Audits HTTP caching strategies across all page resources, identifying resources without cache, short-lived cache configurations, cache anti-patterns, and CDN cache hit rates. Provides a cache efficiency score and actionable recommendations.

### What this snippet analyzes

- Cache strategy classification for every resource (immutable, long, medium, short, no-cache, no-store, etc.)
- Resources missing cache headers entirely
- Static assets with insufficient cache duration
- Cache anti-patterns (versioned files without `immutable`, large uncached resources, outdated `Expires` usage)
- CDN cache hit/miss detection (Cloudflare, Vercel, CloudFront, and generic proxies)
- Cache efficiency score and estimated bandwidth savings
- Protocol distribution (HTTP/1.1 vs H2/H3)
- Uncompressed text resources and duplicate resource loads

### How the browser decides to serve a resource

Diagram: Decision tree for a resource request: a cache miss or a no-store directive goes to the network, a fresh max-age serves from cache, and no-cache or an expired max-age revalidates with the server, where a 304 serves from cache and a 200 updates it

### Why caching matters for performance

| Metric        | Impact                                                     |
| ------------- | ---------------------------------------------------------- |
| LCP           | Cached resources load instantly, no network round-trip     |
| TTFB          | CDN cache hits dramatically reduce server response time    |
| Bandwidth     | Cached resources don't consume data (mobile users benefit) |
| Repeat visits | Good caching makes subsequent page loads near-instant      |

### Snippet

```js
// Cache Strategy Analysis
// Analyzes HTTP caching strategies, CDN hit rates, and cache anti-patterns across all page resources
// https://webperf-snippets.net

(async () => {
  // @shared ownRequestUrl
  // The URL a snippet requests when it reads a file of the page itself (fetch, a HEAD request,
  // new Image()). The fragment never reaches the server or the HTTP cache key, so the file still
  // comes from the cache, but it stays in the name of the Resource Timing entry of the request,
  // which is how isOwnRequest leaves these requests out of what the page loaded.
  function ownRequestUrl(url) {
    try {
      const parsed = new URL(url, location.href);
      if (!/^https?:$/.test(parsed.protocol)) return url;
      parsed.hash = "webperf-snippets";
      return parsed.href;
    } catch {
      return url;
    }
  }
  // @end-shared ownRequestUrl

  // @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

  // Domains that belong to the site but differ from the page's root domain, such as its own CDN.
  // They count as first party. Example: const OWN_DOMAINS = ["bbci.co.uk", "bbc.co.uk"];
  const OWN_DOMAINS = [];

  // --- Helpers ---

  // @shared getRootDomain
  function getRootDomain(hostname) {
    const host = hostname.replace(/\.$/, "");
    // An IP address has no registrable domain, so each address is its own root
    if (/^\d{1,3}(\.\d{1,3}){3}$/.test(host) || host.includes(":")) return host;
    const parts = host.split(".");
    if (parts.length <= 2) return host;
    // Country-code domains with a second-level suffix: example.co.uk, example.com.au, example.ac.jp
    const secondLevelSuffixes = ["ac", "co", "com", "edu", "go", "gob", "gouv", "gov", "govt", "mil", "ne", "net", "nom", "or", "org", "sch"];
    const tld = parts[parts.length - 1];
    const sld = parts[parts.length - 2];
    if (tld.length === 2 && secondLevelSuffixes.includes(sld)) return parts.slice(-3).join(".");
    return parts.slice(-2).join(".");
  }
  // @end-shared getRootDomain

  // @shared isFirstParty
  function isFirstParty(hostname) {
    const root = getRootDomain(hostname);
    if (root === getRootDomain(location.hostname)) return true;
    return OWN_DOMAINS.some((d) => getRootDomain(String(d).trim().toLowerCase().replace(/^[a-z]+:\/\//, "").split("/")[0]) === root);
  }
  // @end-shared isFirstParty

  // @shared logOwnDomainsHint
  function logOwnDomainsHint(thirdPartyCount) {
    if (OWN_DOMAINS.length === 0 && thirdPartyCount > 0) {
      console.log(
        '%cℹ️ OWN_DOMAINS is empty. If this site serves its own assets from other domains (for example a CDN), add them at the top of the snippet, such as const OWN_DOMAINS = ["cdn.example.net"];, and run it again so they count as first party.',
        "color: #3b82f6;"
      );
    }
  }
  // @end-shared logOwnDomainsHint

  // @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

  function getResourceType(entry) {
    // Use file extension as primary signal — initiatorType reflects the loader
    // (e.g. a font loaded from CSS has initiatorType "css", not "font")
    const path = (entry.name || "").split("?")[0].toLowerCase();
    if (/\.(woff2?|ttf|otf|eot)$/.test(path)) return "font";
    if (/\.(png|jpe?g|gif|webp|avif|svg|ico|bmp)$/.test(path)) return "image";
    if (/\.(mp4|webm|ogg|mov)$/.test(path)) return "video";
    if (/\.(mp3|wav|flac|aac)$/.test(path)) return "audio";
    if (/\.css$/.test(path)) return "style";
    if (/\.js$/.test(path)) return "script";

    // Fall back to initiatorType map
    const map = {
      script: "script",
      link: "style",
      css: "style",
      img: "image",
      image: "image",
      video: "video",
      audio: "audio",
      font: "font",
      fetch: "fetch",
      xmlhttprequest: "xhr",
      iframe: "iframe",
      other: "other",
    };
    return map[entry.initiatorType] || entry.initiatorType || "other";
  }

  function detectHashInUrl(url) {
    // Detects content-hash patterns in file paths or query strings.
    // Real content hashes are hex-only (a-f0-9) — broad alphanumeric patterns
    // produce false positives on descriptive filenames like "sprite-placeholder.png".
    const hashPatterns = [
      // Hex hash embedded in filename: .abc12345def.ext or -abc12345def.ext (min 8 hex chars)
      /[.-][a-f0-9]{8,32}\.(js|css|woff2?|ttf|eot|svg|png|jpe?g|webp|avif)(\?|$)/,
      // Query string version params: ?v=abc123, ?hash=..., ?rev=...
      /[?&](v|version|hash|rev|_v)=[a-zA-Z0-9._-]+/,
    ];
    return hashPatterns.some((re) => re.test(url));
  }

  function isStaticAsset(type) {
    return ["script", "style", "font", "image"].includes(type);
  }

  function parseCacheControl(headerValue) {
    const directives = {};
    if (!headerValue) return directives;
    for (const part of headerValue.split(",")) {
      const [key, val] = part.trim().split("=");
      const k = key.trim().toLowerCase();
      directives[k] = val !== undefined ? val.trim().replace(/"/g, "") : true;
    }
    return directives;
  }

  function classifyCacheStrategy(headers) {
    if (!headers) return "unknown";

    const cc = parseCacheControl(headers["cache-control"] || "");
    const hasEtag = !!headers["etag"];
    const hasLastModified = !!headers["last-modified"];
    const hasExpires = !!headers["expires"];
    const hasValidators = hasEtag || hasLastModified;

    if (cc["no-store"]) return "no-store";
    if (cc["no-cache"]) return "no-cache";
    if (cc["immutable"]) return "immutable";
    if (cc["stale-while-revalidate"]) return "swr";
    if (cc["must-revalidate"] && hasValidators) return "must-revalidate";

    const maxAge =
      cc["max-age"] !== undefined ? parseInt(cc["max-age"], 10) : null;
    const sMaxAge =
      cc["s-maxage"] !== undefined ? parseInt(cc["s-maxage"], 10) : null;
    const effectiveMaxAge = sMaxAge ?? maxAge;

    if (effectiveMaxAge !== null) {
      if (effectiveMaxAge === 0) return "no-cache";
      if (effectiveMaxAge < 3600) return "short";
      if (effectiveMaxAge <= 86400) return "medium";
      return "long";
    }

    if (hasExpires && !headers["cache-control"]) return "expires-only";
    if (Object.keys(cc).length === 0 && !hasExpires) return "none";

    return "unknown";
  }

  function detectCdnStatus(headers) {
    if (!headers) return { cdn: "unknown", status: "unknown" };

    if (headers["cf-cache-status"]) {
      return { cdn: "Cloudflare", status: headers["cf-cache-status"] };
    }
    if (headers["x-vercel-cache"]) {
      return { cdn: "Vercel", status: headers["x-vercel-cache"] };
    }
    if (headers["x-cache"]) {
      const val = headers["x-cache"].toUpperCase();
      const cdn = headers["x-amz-cf-id"] ? "CloudFront" : "CDN";
      return { cdn, status: val.includes("HIT") ? "HIT" : "MISS" };
    }
    if (headers["age"]) {
      return {
        cdn: "Proxy/CDN",
        status: `HIT (Age: ${headers["age"]}s)`,
      };
    }
    if (headers["via"]) {
      return { cdn: `Proxy (${headers["via"]})`, status: "proxied" };
    }
    if (headers["server-timing"]) {
      const st = headers["server-timing"].toLowerCase();
      if (st.includes("cdn-cache") || st.includes("cache;")) {
        return {
          cdn: "CDN (Server-Timing)",
          status: st.includes("hit") ? "HIT" : "MISS",
        };
      }
    }
    return { cdn: "none", status: "no-cdn-headers" };
  }

  // --- Phase 1: Gather resource entries ---

  // The HEAD requests this snippet makes in Phase 2 carry the ownRequestUrl tag, so a second run
  // leaves them out
  const resources = performance.getEntriesByType("resource").filter((e) => !isOwnRequest(e));

  // Detect duplicate URLs
  const urlCounts = new Map();
  for (const r of resources) {
    urlCounts.set(r.name, (urlCounts.get(r.name) || 0) + 1);
  }

  const entries = resources.map((r) => {
    let url;
    try {
      url = new URL(r.name);
    } catch {
      url = { hostname: location.hostname, pathname: r.name };
    }
    const firstParty = isFirstParty(url.hostname);
    const isCached = r.encodedBodySize > 0 && r.transferSize === 0;
    const isCorsRestricted =
      r.encodedBodySize === 0 && r.transferSize === 0 && !firstParty;

    return {
      url: r.name,
      shortName:
        (url.pathname || r.name).split("/").filter(Boolean).pop() ||
        url.hostname,
      host: url.hostname,
      firstParty,
      type: getResourceType(r),
      transferSize: r.transferSize || 0,
      encodedBodySize: r.encodedBodySize || 0,
      decodedBodySize: r.decodedBodySize || 0,
      isCached,
      isCorsRestricted,
      nextHopProtocol: r.nextHopProtocol || "unknown",
      duration: r.duration,
      hasHash: detectHashInUrl(r.name),
      isDuplicate: (urlCounts.get(r.name) || 1) > 1,
      // Populated in Phase 2
      headers: null,
      cacheStrategy: null,
      cdnInfo: null,
      antiPatterns: [],
    };
  });

  // --- Phase 2: Fetch headers for same-origin static assets ---

  // Scope: true same-origin only (same scheme+host+port, not just root domain).
  // isFirstParty() matches root domains (api.example.com = first party for www.example.com)
  // but the browser enforces CORS per full origin, causing console errors.
  // Also limit to static asset types — API endpoints and iframes tend to reject HEAD
  // (503/403) and pollute the console without adding useful cache strategy data.
  const FETCHABLE_TYPES = new Set(["script", "style", "font", "image"]);

  const fetchableEntries = entries.filter(
    (e) => e.url.startsWith(location.origin + "/") && FETCHABLE_TYPES.has(e.type)
  );
  const uniqueUrls = [...new Set(fetchableEntries.map((e) => e.url))];

  const headerCache = new Map();
  const BATCH_SIZE = 10;

  for (let i = 0; i < uniqueUrls.length; i += BATCH_SIZE) {
    const batch = uniqueUrls.slice(i, i + BATCH_SIZE);
    await Promise.allSettled(
      batch.map(async (url) => {
        try {
          const resp = await fetch(ownRequestUrl(url), { method: "HEAD", cache: "no-store" });
          if (!resp.ok) {
            headerCache.set(url, null);
            return;
          }
          const headers = {};
          for (const [key, value] of resp.headers) {
            headers[key.toLowerCase()] = value;
          }
          headerCache.set(url, headers);
        } catch {
          // Network error or CORS — skip silently, Tier 1 heuristics apply
          headerCache.set(url, null);
        }
      })
    );
  }

  // Attach headers to entries
  for (const entry of entries) {
    entry.headers = headerCache.get(entry.url) || null;
  }

  // --- Phase 3: Classify cache strategies ---

  for (const entry of entries) {
    if (entry.headers) {
      entry.cacheStrategy = classifyCacheStrategy(entry.headers);
    } else if (entry.isCorsRestricted) {
      entry.cacheStrategy = "unknown (CORS)";
    } else if (entry.isCached) {
      // Tier 1 heuristic: no headers but we know it was cached
      entry.cacheStrategy = "cached (inferred)";
    } else {
      entry.cacheStrategy = "unknown";
    }
    entry.cdnInfo = detectCdnStatus(entry.headers);
  }

  // --- Phase 4: Detect anti-patterns ---

  const EFFECTIVE_CACHE_STRATEGIES = new Set([
    "immutable",
    "long",
    "medium",
    "swr",
    "must-revalidate",
    "cached (inferred)",
  ]);

  for (const entry of entries) {
    if (!entry.headers) continue;
    const cc = parseCacheControl(entry.headers["cache-control"] || "");
    const maxAge =
      cc["max-age"] !== undefined ? parseInt(cc["max-age"], 10) : null;
    const strategy = entry.cacheStrategy;
    const type = entry.type;

    if (isStaticAsset(type) && cc["no-store"]) {
      entry.antiPatterns.push({
        id: "static-no-store",
        severity: "error",
        message: `${type} asset with no-store; static assets should be cacheable`,
      });
    }

    if (entry.hasHash && maxAge !== null && maxAge < 86400 && !cc["no-store"]) {
      entry.antiPatterns.push({
        id: "versioned-short-cache",
        severity: "warning",
        message: `Versioned URL (hash detected) but max-age=${maxAge}s: consider long cache or immutable`,
      });
    }

    if (entry.hasHash && !cc["immutable"] && !cc["no-store"] && (maxAge === null || maxAge >= 86400)) {
      entry.antiPatterns.push({
        id: "versioned-not-immutable",
        severity: "info",
        message: `Versioned URL (hash detected) without Cache-Control: immutable`,
      });
    }

    if (
      entry.decodedBodySize > 100 * 1024 &&
      ["none", "no-store", "no-cache"].includes(strategy)
    ) {
      entry.antiPatterns.push({
        id: "large-no-cache",
        severity: "error",
        message: `Large resource (${formatBytes(entry.decodedBodySize)}) with no effective cache`,
      });
    }

    if (entry.headers["expires"] && !entry.headers["cache-control"]) {
      entry.antiPatterns.push({
        id: "expires-without-cc",
        severity: "warning",
        message: `Uses Expires header without Cache-Control (outdated pattern)`,
      });
    }

    if (isStaticAsset(type) && maxAge === 0) {
      entry.antiPatterns.push({
        id: "maxage-zero-static",
        severity: "warning",
        message: `Static asset with max-age=0, which forces revalidation on every request`,
      });
    }

    if (
      isStaticAsset(type) &&
      maxAge !== null &&
      maxAge > 0 &&
      maxAge < 3600 &&
      !cc["no-store"]
    ) {
      entry.antiPatterns.push({
        id: "short-cache-static",
        severity: "warning",
        message: `Static asset with max-age=${maxAge}s (< 1h): consider a longer cache duration`,
      });
    }
  }

  // --- Phase 5: CDN analysis ---

  const cdnEntries = entries.filter(
    (e) => e.cdnInfo && e.cdnInfo.cdn !== "none" && e.cdnInfo.cdn !== "unknown"
  );
  const cdnHits = cdnEntries.filter((e) =>
    e.cdnInfo.status.toUpperCase().startsWith("HIT")
  ).length;
  const cdnHitRate =
    cdnEntries.length > 0
      ? Math.round((cdnHits / cdnEntries.length) * 100)
      : null;

  // --- Phase 6: Value-add metrics ---

  // Only resources whose cache headers were read have a cache policy to judge. One whose headers
  // could not be read, or that was only inferred as cached, says nothing about the policy: counting it
  // as "not effective" made the figure measure how much was analyzable instead of the cache.
  const analyzableEntries = entries.filter((e) => e.headers !== null);
  const effectiveEntries = analyzableEntries.filter((e) =>
    EFFECTIVE_CACHE_STRATEGIES.has(e.cacheStrategy)
  );
  const cacheEfficiencyPercent =
    analyzableEntries.length > 0
      ? Math.round((effectiveEntries.length / analyzableEntries.length) * 100)
      : null;

  const bandwidthSavedBytes = entries
    .filter((e) => e.isCached)
    .reduce((sum, e) => sum + e.decodedBodySize, 0);

  // Protocol distribution
  const protocolDistribution = {};
  for (const entry of entries) {
    const proto = entry.nextHopProtocol || "unknown";
    protocolDistribution[proto] = (protocolDistribution[proto] || 0) + 1;
  }

  // Uncompressed text resources (encoded ≈ decoded, size > 1KB, text type)
  const uncompressedResources = entries.filter(
    (e) =>
      e.encodedBodySize > 1024 &&
      e.decodedBodySize > 0 &&
      e.encodedBodySize / e.decodedBodySize > 0.9 &&
      ["script", "style", "fetch", "xhr"].includes(e.type)
  );

  // Duplicate resources — group by URL and track whether any load was uncached
  const uniqueDuplicates = [...new Set(
    entries.filter((e) => e.isDuplicate).map((e) => e.url)
  )].map((url) => {
    const group = entries.filter((e) => e.url === url);
    const uncachedLoads = group.filter((e) => !e.isCached).length;
    return {
      url,
      shortName: group[0]?.shortName || url,
      count: urlCounts.get(url),
      type: group[0]?.type || "unknown",
      uncachedLoads,
      // Only "costly" if at least one load went to the network
      costly: uncachedLoads > 0,
    };
  });
  // Separate costly duplicates (network requests) from cached duplicates (cheap)
  const costlyDuplicates = uniqueDuplicates.filter((d) => d.costly);
  const cachedDuplicates = uniqueDuplicates.filter((d) => !d.costly);

  // These two findings need no cache headers, so they also cover resources on other origins. They
  // are anti-patterns like the header-based ones: they reach `items` and `issues`.
  for (const entry of uncompressedResources) {
    entry.antiPatterns.push({
      id: "uncompressed",
      severity: "warning",
      message: `${entry.type} sent uncompressed (${formatBytes(entry.encodedBodySize)} on the wire for ${formatBytes(entry.decodedBodySize)}): enable Brotli or gzip`,
    });
  }
  for (const duplicate of costlyDuplicates) {
    // One finding per URL, on the first request, not one per request
    entries.find((e) => e.url === duplicate.url).antiPatterns.push({
      id: "duplicate-uncached",
      severity: "warning",
      message: `Loaded ${duplicate.count} times, ${duplicate.uncachedLoads} from the network: load it once or make it cacheable`,
    });
  }

  // --- Phase 7: Console output ---

  if (entries.length === 0) {
    console.group("%c🔍 Cache Strategy Analysis", "font-weight: bold; font-size: 14px;");
    console.warn("⚠️ No resources found in the Performance API buffer.");
    console.log("   This usually means the site called performance.clearResourceTimings() to manage memory.");
    console.log("   Try: reload the page and run this snippet immediately after load.");
    console.groupEnd();
    return {
      script: "Cache-Strategy-Analysis",
      status: "error",
      error: "No resources in the Performance API buffer (performance.clearResourceTimings() may have been called). Reload the page and run the snippet again.",
    };
  }

  const allAntiPatterns = entries.flatMap((e) =>
    e.antiPatterns.map((ap) => ({ ...ap, resource: e.shortName, url: e.url }))
  );

  const strategyGroups = {};
  for (const entry of entries) {
    const s = entry.cacheStrategy;
    if (!strategyGroups[s]) strategyGroups[s] = 0;
    strategyGroups[s]++;
  }

  const noCacheEntries = entries.filter((e) =>
    ["none", "no-store", "no-cache"].includes(e.cacheStrategy)
  );
  const shortCacheEntries = entries.filter(
    (e) => e.cacheStrategy === "short"
  );

  const headersAnalyzed = entries.filter((e) => e.headers !== null).length;
  const headerCoveragePercent = Math.round((headersAnalyzed / entries.length) * 100);
  const corsRestricted = entries.filter((e) => e.isCorsRestricted).length;

  // Resources excluded from header analysis — cross-origin or non-static types.
  // Cross-origin (different scheme/host/port) cannot be fetched due to CORS.
  // Non-static types (xhr, fetch, iframe, beacon…) are excluded intentionally:
  // their servers commonly reject HEAD requests (4xx/5xx) and their caching
  // rules differ from static assets — they are still visible in Tier 1 data.
  const excludedFromHeaders = entries.filter(
    (e) =>
      e.headers === null &&
      !e.isCorsRestricted &&
      (!e.url.startsWith(location.origin + "/") ||
        !FETCHABLE_TYPES.has(e.type))
  );

  // Header analysis only reaches same-origin static assets. When most of the page is elsewhere (a CDN
  // or another origin for the assets), say so and name the hosts, so an empty list is not read as "clean".
  let coverageIssue = null;
  if (headerCoveragePercent < 50) {
    const hostCounts = new Map();
    for (const e of entries.filter((e) => e.headers === null)) {
      hostCounts.set(e.host, (hostCounts.get(e.host) || 0) + 1);
    }
    const topHosts = [...hostCounts.entries()]
      .sort((a, b) => b[1] - a[1])
      .slice(0, 3)
      .map(([host]) => host)
      .join(", ");
    coverageIssue = {
      severity: "info",
      id: "header-coverage",
      message: `Cache headers were read for ${headersAnalyzed} of ${entries.length} resources (${headerCoveragePercent}%): the rest are on other origins or are not static assets, so their Cache-Control cannot be read from the page. Most of them are on ${topHosts}; check those hosts in the Network panel or with curl -I`,
    };
  }

  console.group(
    "%c🔍 Cache Strategy Analysis",
    "font-weight: bold; font-size: 14px;"
  );

  console.log("");
  console.log("%c📊 Summary", "font-weight: bold;");
  console.log(`   Total resources: ${entries.length}`);
  console.log(
    `   Headers analyzed: ${headersAnalyzed} | CORS-restricted: ${corsRestricted} | Excluded (non-static/cross-origin): ${excludedFromHeaders.length}`
  );
  if (coverageIssue) console.log(`   ℹ️  ${coverageIssue.message}`);
  if (excludedFromHeaders.length > 0) {
    const excludedTypes = [...new Set(excludedFromHeaders.map((e) => e.type))].join(", ");
    console.log(
      `   ℹ️  Excluded resources (${excludedTypes}) are analyzed with Tier 1 data only (Performance API) — header inspection requires same-origin static assets due to CORS restrictions.`
    );
  }
  console.log(
    cacheEfficiencyPercent === null
      ? `   Cache efficiency: n/a (no cache headers could be read)`
      : `   Cache efficiency: ${cacheEfficiencyPercent}% (of the ${analyzableEntries.length} resources whose cache headers could be read)`
  );
  console.log(`   Bandwidth saved by cache: ${formatBytes(bandwidthSavedBytes)}`);
  if (cdnHitRate !== null) {
    console.log(
      `   CDN hit rate: ${cdnHitRate}% (${cdnHits}/${cdnEntries.length} resources)`
    );
  }
  if (allAntiPatterns.length > 0) {
    const errors = allAntiPatterns.filter((p) => p.severity === "error").length;
    const warnings = allAntiPatterns.filter(
      (p) => p.severity === "warning"
    ).length;
    const infos = allAntiPatterns.filter((p) => p.severity === "info").length;
    console.log(
      `   Anti-patterns: ${allAntiPatterns.length} (${errors} errors, ${warnings} warnings, ${infos} info)`
    );
  }

  // Strategy distribution
  console.log("");
  console.group("%c📋 Cache Strategy Distribution", "font-weight: bold;");
  const strategyOrder = [
    "immutable",
    "long",
    "medium",
    "short",
    "swr",
    "must-revalidate",
    "no-cache",
    "no-store",
    "expires-only",
    "none",
    "cached (inferred)",
    "unknown (CORS)",
    "unknown",
  ];
  const strategyTable = strategyOrder
    .filter((s) => strategyGroups[s])
    .map((s) => ({
      Strategy: s,
      Count: strategyGroups[s],
      "% of Total": `${Math.round((strategyGroups[s] / entries.length) * 100)}%`,
      Effective: EFFECTIVE_CACHE_STRATEGIES.has(s) ? "✅" : "❌",
    }));
  console.table(strategyTable);
  console.groupEnd();

  // Resources without cache
  if (noCacheEntries.length > 0) {
    console.log("");
    console.group(
      `%c🚫 Resources Without Cache (${noCacheEntries.length})`,
      "font-weight: bold; color: #ef4444;"
    );
    console.table(
      noCacheEntries.map((e) => ({
        Resource: e.shortName,
        Type: e.type,
        Strategy: e.cacheStrategy,
        Size: formatBytes(e.decodedBodySize),
        Host: e.host,
        "First Party": e.firstParty ? "✅" : "❌",
      }))
    );
    console.groupEnd();
  }

  // Short cache resources
  if (shortCacheEntries.length > 0) {
    console.log("");
    console.group(
      `%c⏱ Short Cache Resources (${shortCacheEntries.length})`,
      "font-weight: bold; color: #f59e0b;"
    );
    console.table(
      shortCacheEntries.map((e) => {
        const cc = parseCacheControl(e.headers?.["cache-control"] || "");
        return {
          Resource: e.shortName,
          Type: e.type,
          "max-age": cc["max-age"] ? `${cc["max-age"]}s` : "N/A",
          Size: formatBytes(e.decodedBodySize),
          "Has Hash": e.hasHash ? "✅" : "❌",
          Host: e.host,
        };
      })
    );
    console.groupEnd();
  }

  // Anti-patterns
  if (allAntiPatterns.length > 0) {
    console.log("");
    console.group(
      `%c⚠️ Cache Anti-patterns (${allAntiPatterns.length})`,
      "font-weight: bold; color: #f59e0b;"
    );
    console.groupCollapsed("📖 Legend");
    console.table([
      { Pattern: "static-no-store",        Severity: "🔴 error",   Meaning: "JS/CSS/font/image with no-store — static assets should always be cacheable" },
      { Pattern: "large-no-cache",          Severity: "🔴 error",   Meaning: "Resource > 100 KB with no effective cache — significant bandwidth waste per visit" },
      { Pattern: "versioned-short-cache",   Severity: "🟡 warning", Meaning: "Hashed URL with max-age < 86400 — safe to use immutable since URL changes with content" },
      { Pattern: "expires-without-cc",      Severity: "🟡 warning", Meaning: "Uses Expires header without Cache-Control — outdated pattern, less reliable" },
      { Pattern: "maxage-zero-static",      Severity: "🟡 warning", Meaning: "Static asset with max-age=0 — browser revalidates on every request" },
      { Pattern: "short-cache-static",      Severity: "🟡 warning", Meaning: "Static asset with max-age < 3600 — consider a longer cache duration" },
      { Pattern: "uncompressed",            Severity: "🟡 warning", Meaning: "Text resource sent without Brotli or gzip — works for any origin, no headers needed" },
      { Pattern: "duplicate-uncached",      Severity: "🟡 warning", Meaning: "Same URL loaded more than once with at least one request to the network" },
      { Pattern: "versioned-not-immutable", Severity: "🔵 info",    Meaning: "Hashed URL without immutable directive — adding it avoids conditional GET requests" },
    ]);
    console.groupEnd();
    for (const severity of ["error", "warning", "info"]) {
      const filtered = allAntiPatterns.filter((p) => p.severity === severity);
      if (filtered.length === 0) continue;
      const icon = { error: "🔴", warning: "🟡", info: "🔵" }[severity];
      console.group(`${icon} ${severity.charAt(0).toUpperCase() + severity.slice(1)} (${filtered.length})`);
      console.table(
        filtered.map((p) => ({
          Resource: p.resource,
          Pattern: p.id,
          Detail: p.message,
        }))
      );
      console.groupEnd();
    }
    console.groupEnd();
  }

  // CDN status
  if (cdnEntries.length > 0) {
    console.log("");
    console.group(
      `%c🌐 CDN Cache Status`,
      "font-weight: bold; color: #3b82f6;"
    );
    console.log(
      `   Hit rate: ${cdnHitRate}% (${cdnHits} hits / ${cdnEntries.length} CDN resources)`
    );
    console.table(
      cdnEntries.map((e) => ({
        Resource: e.shortName,
        CDN: e.cdnInfo.cdn,
        Status: e.cdnInfo.status,
        "Cache-Control": e.headers?.["cache-control"] || "N/A",
        Type: e.type,
      }))
    );
    console.groupEnd();
  }

  // Additional insights
  console.log("");
  console.group("%c💡 Additional Insights", "font-weight: bold;");

  // Protocol
  console.group("Protocol Distribution");
  console.table(
    Object.entries(protocolDistribution)
      .sort((a, b) => b[1] - a[1])
      .map(([proto, count]) => ({
        Protocol: proto,
        Count: count,
        "% of Total": `${Math.round((count / entries.length) * 100)}%`,
      }))
  );
  console.groupEnd();

  // Uncompressed
  if (uncompressedResources.length > 0) {
    console.group(`Likely Uncompressed Resources (${uncompressedResources.length})`);
    console.table(
      uncompressedResources.map((e) => ({
        Resource: e.shortName,
        Type: e.type,
        "Encoded Size": formatBytes(e.encodedBodySize),
        "Decoded Size": formatBytes(e.decodedBodySize),
        "Compression Ratio": `${((1 - e.encodedBodySize / e.decodedBodySize) * 100).toFixed(0)}%`,
      }))
    );
    console.groupEnd();
  }

  // Duplicates — show costly (network) first, then cached-only
  if (costlyDuplicates.length > 0) {
    console.group(
      `Duplicate Resources — Network Impact (${costlyDuplicates.length} URLs with uncached loads)`
    );
    console.table(
      costlyDuplicates.map((d) => ({
        Resource: d.shortName,
        Type: d.type,
        "Times Loaded": d.count,
        "Uncached Loads": d.uncachedLoads,
      }))
    );
    console.groupEnd();
  }
  if (cachedDuplicates.length > 0) {
    console.group(
      `Duplicate Resources — Cache Only (${cachedDuplicates.length} URLs loaded multiple times, all cached)`
    );
    console.table(
      cachedDuplicates.map((d) => ({
        Resource: d.shortName,
        Type: d.type,
        "Times Loaded": d.count,
      }))
    );
    console.groupEnd();
  }

  console.groupEnd(); // Additional Insights

  // Recommendations
  const recommendations = [];

  if (noCacheEntries.filter((e) => isStaticAsset(e.type)).length > 0) {
    recommendations.push(
      "🔴 Add Cache-Control headers to static assets (JS, CSS, fonts, images). Start with max-age=86400 and move to immutable for versioned files."
    );
  }
  if (shortCacheEntries.filter((e) => e.hasHash).length > 0) {
    recommendations.push(
      "🟡 Versioned files (with content hash) should use long cache or Cache-Control: immutable; they can be cached indefinitely since the URL changes on update."
    );
  }
  if (allAntiPatterns.some((p) => p.id === "expires-without-cc")) {
    recommendations.push(
      "🟡 Replace Expires headers with Cache-Control: max-age. Expires is an outdated mechanism and less reliable."
    );
  }
  if (cacheEfficiencyPercent !== null && cacheEfficiencyPercent < 50) {
    recommendations.push(
      `🔴 Cache efficiency is low (${cacheEfficiencyPercent}%, measured on the ${analyzableEntries.length} resources whose cache headers could be read). Review caching strategy for those static assets.`
    );
  }
  if (cdnHitRate !== null && cdnHitRate < 70) {
    recommendations.push(
      `🟡 CDN hit rate is ${cdnHitRate}%. Check CDN cache rules and ensure static assets have appropriate TTLs.`
    );
  }
  if (protocolDistribution["http/1.1"] > 0) {
    recommendations.push(
      `🟡 ${protocolDistribution["http/1.1"]} resources still use HTTP/1.1. Consider upgrading to HTTP/2 for better multiplexing.`
    );
  }
  if (uncompressedResources.length > 0) {
    recommendations.push(
      `🟡 ${uncompressedResources.length} text resources appear uncompressed. Enable Brotli or gzip compression on your server.`
    );
  }
  if (costlyDuplicates.length > 0) {
    recommendations.push(
      `🟡 ${costlyDuplicates.length} resources are loaded multiple times with uncached network requests. Check for duplicate script, stylesheet, or fetch includes.`
    );
  } else if (cachedDuplicates.length > 0) {
    recommendations.push(
      `ℹ️ ${cachedDuplicates.length} resources are requested multiple times but served from cache; low performance impact, but worth reviewing for correctness.`
    );
  }
  if (corsRestricted > 0 && headersAnalyzed === 0) {
    recommendations.push(
      `ℹ️ All resources are cross-origin CORS-restricted, so header analysis was limited. Add Timing-Allow-Origin for more insights.`
    );
  }

  if (recommendations.length > 0) {
    console.log("");
    console.group("%c📌 Recommendations", "font-weight: bold;");
    for (const rec of recommendations) {
      console.log(`   ${rec}`);
    }
    console.groupEnd();
  }

  logOwnDomainsHint(entries.filter((e) => !e.firstParty).length);
  console.groupEnd(); // Cache Strategy Analysis

  // --- Return structured object ---

  const cacheEfficiencyRating =
    cacheEfficiencyPercent === null
      ? null
      : cacheEfficiencyPercent >= 80
      ? "good"
      : cacheEfficiencyPercent >= 50
      ? "needs-improvement"
      : "poor";

  const MAX_ITEMS = 50;
  const actionableEntries = entries.filter(
    (e) =>
      e.antiPatterns.length > 0 ||
      ["none", "no-store", "no-cache", "short"].includes(e.cacheStrategy)
  );
  return {
    script: "Cache-Strategy-Analysis",
    status: "ok",
    count: entries.length,
    corsLimitedAnalysis: corsRestricted > 0,
    details: {
      totalResources: entries.length,
      actionableCount: actionableEntries.length,
      headersAnalyzed,
      corsRestricted,
      excludedFromHeaders: excludedFromHeaders.length,
      headerCoveragePercent,
      cacheEfficiencyPercent,
      cacheEfficiencyRating,
      cacheEfficiencyResources: analyzableEntries.length,
      bandwidthSavedBytes,
      strategyDistribution: strategyGroups,
      cdnDetected: cdnEntries.length > 0,
      cdnHitRate,
      cdnHitRateRating:
        cdnHitRate === null ? null : cdnHitRate >= 70 ? "good" : "poor",
      antiPatternCount: allAntiPatterns.length,
      duplicatesCostlyCount: costlyDuplicates.length,
      uncompressedCount: uncompressedResources.length,
      protocolDistribution,
    },
    // Only resources with actionable findings (cache anti-patterns, a weak cache strategy, a costly
    // duplicate or uncompressed text), at most 50: the ones with the most anti-patterns first, then the
    // largest. Clean resources are left out to reduce noise.
    items: actionableEntries
      .sort((a, b) => b.antiPatterns.length - a.antiPatterns.length || b.decodedBodySize - a.decodedBodySize)
      .slice(0, MAX_ITEMS)
      .map((e) => ({
        shortName: e.shortName,
        host: e.host,
        type: e.type,
        firstParty: e.firstParty,
        cacheStrategy: e.cacheStrategy,
        sizeBytes: e.decodedBodySize,
        isCached: e.isCached,
        antiPatterns: e.antiPatterns.map((p) => p.id),
      })),
    issues: [
      ...allAntiPatterns.map((p) => ({
        severity: p.severity,
        id: p.id,
        resource: p.resource,
        message: p.message,
      })),
      ...(coverageIssue ? [coverageIssue] : []),
    ],
    recommendations,
  };
})();
```

## Understanding the results

### Cache strategy distribution

Every resource is classified into one of these strategies:

| Strategy            | Cache-Control directive                   | Typical use case                              |
| ------------------- | ----------------------------------------- | --------------------------------------------- |
| `immutable`         | `max-age=31536000, immutable`             | Versioned/hashed static assets                |
| `long`              | `max-age > 86400` (> 1 day)               | Static assets with long lifetime              |
| `medium`            | `max-age 3600–86400` (1h–1d)              | Semi-static content                           |
| `short`             | `max-age < 3600` (< 1h)                   | Frequently updated content                    |
| `swr`               | `stale-while-revalidate`                  | Content served stale while background refresh |
| `must-revalidate`   | `must-revalidate` + ETag/Last-Modified    | Documents that must be fresh                  |
| `no-cache`          | `no-cache` or `max-age=0`                 | Forces revalidation every request             |
| `no-store`          | `no-store`                                | Never cached (sensitive data)                 |
| `expires-only`      | Only `Expires` header, no `Cache-Control` | Legacy servers (outdated)                     |
| `none`              | No caching headers at all                 | Missing cache configuration                   |
| `unknown (CORS)`    | Cross-origin, headers not readable        | Add `Timing-Allow-Origin` for visibility      |
| `cached (inferred)` | `transferSize=0` but no readable headers  | Browser cached, strategy unknown              |

The **Cache Efficiency Score** shows what percentage of the resources whose cache headers could be read have an effective cache strategy (`immutable`, `long`, `medium`, `swr`, or `must-revalidate`). Resources whose headers cannot be read (`unknown`, `unknown (CORS)`) and resources only inferred as cached do not count either way, because they say nothing about the cache policy. `details.cacheEfficiencyResources` is the number of resources the score is based on, and the score and its rating are `null` when no header could be read.

### Resources without cache

Resources with strategy `none`, `no-store`, or `no-cache`. For static assets (JS, CSS, fonts, images), this is a performance problem, every page load re-fetches them from the network.

**Fix:** Add `Cache-Control: max-age=31536000, immutable` for versioned assets, or at minimum `max-age=86400` for stable static files.

### Short cache resources

Resources with `max-age < 3600` (less than 1 hour). Pay attention to:

- **Has Hash = ✅**: If the URL contains a content hash, the file changes when its content changes. A short cache is wasted opportunity, use `immutable`.
- **Has Hash = ❌**: The URL is stable, so a short cache forces frequent re-fetches. Increase the TTL if the content changes infrequently.

### Cache anti-patterns

| Pattern                   | Severity   | Meaning                                                                           |
| ------------------------- | ---------- | --------------------------------------------------------------------------------- |
| `static-no-store`         | 🔴 Error   | A JS, CSS, font, or image file has `no-store`, these should always be cacheable   |
| `large-no-cache`          | 🔴 Error   | A resource > 100 KB has no cache, significant bandwidth waste per visit           |
| `versioned-short-cache`   | 🟡 Warning | A hashed URL has `max-age < 86400`, safe to use much longer TTL                   |
| `expires-without-cc`      | 🟡 Warning | Uses only `Expires` header, outdated, less reliable than `Cache-Control`          |
| `maxage-zero-static`      | 🟡 Warning | Static asset with `max-age=0`, browser revalidates on every request               |
| `short-cache-static`      | 🟡 Warning | Static asset with `max-age < 3600`, increase TTL for better performance           |
| `uncompressed`            | 🟡 Warning | A script, stylesheet, or fetch response over 1 KB was sent without Brotli or gzip |
| `duplicate-uncached`      | 🟡 Warning | The same URL was loaded more than once and at least one load went to the network  |
| `versioned-not-immutable` | 🔵 Info    | Hashed URL without `immutable`, adding it avoids conditional GET requests         |

The last two patterns need no response headers, so they apply to resources on any origin. A duplicated URL is reported once, on its first request.

### CDN cache status

Reads CDN-specific response headers to determine cache hit/miss per resource:

| Header            | CDN detected                                     |
| ----------------- | ------------------------------------------------ |
| `CF-Cache-Status` | Cloudflare                                       |
| `X-Vercel-Cache`  | Vercel Edge Network                              |
| `X-Cache`         | CloudFront or generic CDN                        |
| `Age`             | Generic proxy/CDN (presence = served from cache) |
| `Via`             | Any HTTP proxy                                   |
| `Server-Timing`   | CDN that exposes cache info in Server-Timing     |

A **CDN hit rate below 70%** usually indicates the CDN cache is cold, the TTL is too short, or cache-busting headers (`Cache-Control: no-store`, `Vary: *`) are preventing caching at the edge.

## Header analysis scope and CORS limitations

Header inspection (`Cache-Control`, `Expires`, CDN headers) requires issuing `HEAD` requests via `fetch()`. Two categories of resources are excluded from this analysis and fall back to **Tier 1 heuristics** (Performance API data only):

### Cross-origin resources

Resources served from a different origin (different scheme, host, or port) than the current page are blocked by CORS unless the server sends `Access-Control-Allow-Origin`. This includes subdomains, `api.web.dev` is cross-origin from `www.web.dev`.

Those resources appear as **"CORS-restricted"** in the output.

### Non-static resource types

`fetch`, `xhr`, `iframe`, `beacon`, and similar dynamic resource types are excluded intentionally:

- Their servers commonly reject `HEAD` requests with `4xx`/`5xx` errors
- Dynamic endpoints (API calls, tracking pixels) have different caching semantics than static assets
- They produce console noise if fetched (e.g., CSRF token endpoints, analytics beacons)

These resources are still visible in the summary via Tier 1 data:

- `transferSize === 0` and `encodedBodySize > 0` → served from browser cache
- `transferSize > 0` → fetched from the network

### How much of the page was analyzed

`details.headerCoveragePercent` is the share of resources whose cache headers were read. Many sites serve their static assets from another origin, for example `static.example-cdn.net`, so the page can read the headers of few or none of them, and an empty list of header-based findings does not mean the cache is fine. When coverage is below 50%, `issues` carries an `info` entry named `header-coverage` that says how many resources were analyzed and names the hosts that hold most of the rest. To inspect those hosts, use the Network panel or `curl -I` on a few of their assets.

To get header visibility on your own cross-origin or CDN resources, configure the server to send:

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

> **Note:** Resources are first party when they share the page's root domain. A site's own CDN on a different root domain, such as `bbci.co.uk` for `bbc.co.uk`, is reported as a third party, because the comparison uses root domains only. To count those domains as first party, list them in the `OWN_DOMAINS` constant at the top of the snippet, for example `const OWN_DOMAINS = ["bbci.co.uk", "bbc.co.uk"];`. Entries can be domains or URLs. While the list is empty and the page has third-party resources, the snippet prints a reminder in the console.

## Browser support

The snippet needs these features to run.

| Feature                                                                                                                     | Chrome | Edge | Firefox | Safari |
| --------------------------------------------------------------------------------------------------------------------------- | :----: | :--: | :-----: | :----: |
| [PerformanceResourceTiming.transferSize](https://developer.mozilla.org/docs/Web/API/PerformanceResourceTiming/transferSize) |  ✅ 54  | ✅ 17 |   ✅ 45  | ✅ 16.4 |
| [fetch](https://developer.mozilla.org/docs/Web/API/Window/fetch)                                                            |  ✅ 42  | ✅ 14 |   ✅ 39  | ✅ 10.1 |
| All of the above                                                                                                            |  ✅ 54  | ✅ 17 |   ✅ 45  | ✅ 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.nextHopProtocol](https://developer.mozilla.org/docs/Web/API/PerformanceResourceTiming/nextHopProtocol) |  ✅ 61  | ✅ 17 |   ✅ 45  |  ✅ 11  |

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

## Further reading

- [HTTP caching](https://web.dev/articles/http-cache) | web.dev
- [Prevent unnecessary network requests with the HTTP Cache](https://developer.chrome.com/docs/lighthouse/performance/uses-long-cache-ttl) | Chrome Developers
- [Cache-Control](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control) | MDN
- [Stale-while-revalidate](https://web.dev/articles/stale-while-revalidate) | web.dev
- [Content delivery networks (CDNs)](https://web.dev/articles/content-delivery-networks) | web.dev
- [Service Worker Analysis](https://webperf-snippets.net/Loading/Service-Worker-Analysis.md) | Complements cache analysis for SW-managed caches
- [TTFB](https://webperf-snippets.net/Loading/TTFB.md) | CDN cache hits directly reduce TTFB
