Skip to Content
LoadingCache Strategy Analysis

Cache strategy analysis

Chrome 54+ Edge 17+ Firefox 45+ Safari 16.4+ Details

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

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 itConnections: Resource Request to In browser cache?; In browser cache? to Fetch from network (No); In browser cache? to Cache-Control directive? (Yes); Cache-Control directive? to Serve from cache (max-age not expired); Cache-Control directive? to Revalidate with server (max-age expired); Cache-Control directive? to Revalidate with server (no-cache / max-age=0); Cache-Control directive? to Fetch from network (no-store); Revalidate with server to Serve from cache (304 Not Modified); Revalidate with server to New response + update cache (200 OK); Fetch from network to New response + update cache.Resource RequestIn browser cache?Fetch from networkCache-Control directive?Revalidate with serverServe from cacheNew response + update cacheNoYesmax-age not expiredmax-age expiredno-cache / max-age=0no-store304 Not Modified200 OK
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 itConnections: Resource Request to In browser cache?; In browser cache? to Fetch from network (No); In browser cache? to Cache-Control directive? (Yes); Cache-Control directive? to Serve from cache (max-age not expired); Cache-Control directive? to Revalidate with server (max-age expired); Cache-Control directive? to Revalidate with server (no-cache / max-age=0); Cache-Control directive? to Fetch from network (no-store); Revalidate with server to Serve from cache (304 Not Modified); Revalidate with server to New response + update cache (200 OK); Fetch from network to New response + update cache.Resource RequestIn browsercache?Fetch fromnetworkCache-Controldirective?Revalidate withserverServe from cacheNew response +update cacheNoYesmax-age not expiredmax-age expiredno-cache /max-age=0no-store304 Not Modified200 OK

Why caching matters for performance

MetricImpact
LCPCached resources load instantly, no network round-trip
TTFBCDN cache hits dramatically reduce server response time
BandwidthCached resources don’t consume data (mobile users benefit)
Repeat visitsGood caching makes subsequent page loads near-instant

Snippet

Run it from an AI agent: the prompt uses the CLI to measure any URL and report the result.

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

StrategyCache-Control directiveTypical use case
immutablemax-age=31536000, immutableVersioned/hashed static assets
longmax-age > 86400 (> 1 day)Static assets with long lifetime
mediummax-age 3600–86400 (1h–1d)Semi-static content
shortmax-age < 3600 (< 1h)Frequently updated content
swrstale-while-revalidateContent served stale while background refresh
must-revalidatemust-revalidate + ETag/Last-ModifiedDocuments that must be fresh
no-cacheno-cache or max-age=0Forces revalidation every request
no-storeno-storeNever cached (sensitive data)
expires-onlyOnly Expires header, no Cache-ControlLegacy servers (outdated)
noneNo caching headers at allMissing cache configuration
unknown (CORS)Cross-origin, headers not readableAdd Timing-Allow-Origin for visibility
cached (inferred)transferSize=0 but no readable headersBrowser 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

PatternSeverityMeaning
static-no-store ErrorA JS, CSS, font, or image file has no-store, these should always be cacheable
large-no-cache ErrorA resource > 100 KB has no cache, significant bandwidth waste per visit
versioned-short-cache WarningA hashed URL has max-age < 86400, safe to use much longer TTL
expires-without-cc WarningUses only Expires header, outdated, less reliable than Cache-Control
maxage-zero-static WarningStatic asset with max-age=0, browser revalidates on every request
short-cache-static WarningStatic asset with max-age < 3600, increase TTL for better performance
uncompressed WarningA script, stylesheet, or fetch response over 1 KB was sent without Brotli or gzip
duplicate-uncached WarningThe same URL was loaded more than once and at least one load went to the network
versioned-not-immutable InfoHashed 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:

HeaderCDN detected
CF-Cache-StatusCloudflare
X-Vercel-CacheVercel Edge Network
X-CacheCloudFront or generic CDN
AgeGeneric proxy/CDN (presence = served from cache)
ViaAny HTTP proxy
Server-TimingCDN 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:

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

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.

FeatureChromeEdgeFirefoxSafari
PerformanceResourceTiming.transferSize (opens in a new tab) 54 17 45 16.4
fetch (opens in a new tab) 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.

FeatureChromeEdgeFirefoxSafari
PerformanceResourceTiming.nextHopProtocol (opens in a new tab) 61 17 45 11

Source: MDN browser compatibility data (opens in a new tab), version 8.1.4.

Further reading

Last updated on
MIT 2026 © Joan León | @nucliweb