DevTools Overrides
Fetch & XHR Timeline

Fetch & XHR Timeline

Overview

Intercepts every fetch() and XMLHttpRequest call made during the page lifecycle and correlates them with the Largest Contentful Paint (LCP) (opens in a new tab) timing.

Console snippets can only see requests that have already completed and are visible in the Resource Timing API. That excludes cross-origin requests without Timing-Allow-Origin, requests made by service workers, and calls that fail before a response is received. This snippet patches window.fetch and XMLHttpRequest.prototype at page initialization, so every call is captured regardless of origin or outcome.

What it helps diagnose:

  • API calls that complete before LCP and may be blocking the critical rendering path
  • Failed or errored requests during framework bootstrap (Angular, React, Vue resolvers and guards)
  • Unnecessary authentication checks on pages where the user is known to be unauthenticated
  • The full request inventory of a page, including calls invisible to the Network panel timing data

This snippet requires DevTools Overrides. Read What are DevTools Overrides? for setup instructions.


Part 1 — Inject snippet

Add this inside a <script> tag before the closing </head> of the overridden HTML file. It runs before any other script and stores all captured calls in window.__perfCalls.

// Fetch & XHR Timeline — Inject via DevTools Overrides
// https://webperf-snippets.nucliweb.net
(() => {
  const calls = [];

  // Intercept fetch
  const origFetch = window.fetch;
  window.fetch = function (...args) {
    const url = typeof args[0] === "string" ? args[0] : args[0]?.url;
    const start = performance.now();
    return origFetch.apply(this, args).then((r) => {
      const end = performance.now();
      calls.push({
        type: "fetch",
        url,
        start: Math.round(start),
        end: Math.round(end),
        duration: Math.round(end - start),
        status: r.status,
      });
      return r;
    }).catch((err) => {
      const end = performance.now();
      calls.push({
        type: "fetch",
        url,
        start: Math.round(start),
        end: Math.round(end),
        duration: Math.round(end - start),
        status: "ERROR",
        error: err.message,
      });
      throw err;
    });
  };

  // Intercept XHR
  const origOpen = XMLHttpRequest.prototype.open;
  const origSend = XMLHttpRequest.prototype.send;

  XMLHttpRequest.prototype.open = function (method, url) {
    this.__url = url;
    this.__method = method;
    return origOpen.apply(this, arguments);
  };

  XMLHttpRequest.prototype.send = function () {
    const start = performance.now();
    this.addEventListener("loadend", () => {
      const end = performance.now();
      calls.push({
        type: "xhr",
        url: this.__url,
        start: Math.round(start),
        end: Math.round(end),
        duration: Math.round(end - start),
        status: this.status || "ERROR",
      });
    });
    return origSend.apply(this, arguments);
  };

  window.__perfCalls = calls;

  // Capture LCP for correlation in the read snippet
  try {
    new PerformanceObserver((list) => {
      const entries = list.getEntries();
      const last = entries[entries.length - 1];
      window.__lcpTime = Math.round(last.startTime);
    }).observe({ type: "largest-contentful-paint", buffered: true });
  } catch {}
})();

Part 2 — Read snippet

Run this in the console after the page has loaded to see the captured data correlated with LCP.

// Fetch & XHR Timeline — Run in console after page load
// https://webperf-snippets.nucliweb.net

(() => {
  const calls = window.__perfCalls;

  if (!calls) {
    console.warn(
      "No data found. Make sure the inject snippet is active via DevTools Overrides and reload the page."
    );
    return;
  }

  const lcpEntries = performance.getEntriesByType("largest-contentful-paint");
  const lcpTime = lcpEntries.length
    ? Math.round(lcpEntries[lcpEntries.length - 1].startTime)
    : window.__lcpTime ?? null;

  const isAuthCall = (url = "") => {
    try {
      const { pathname, searchParams } = new URL(url, location.href);
      const path = pathname.toLowerCase();
      if (/\/(auth|login|logout|token|session|whoami|identity|oauth)/.test(path)) return true;
      if (/(^|\/)me(\/|$)/.test(path)) return true;
      if (searchParams.has("authIndexType") || searchParams.has("authIndexValue")) return true;
      return false;
    } catch {
      return false;
    }
  };

  const isError = (c) =>
    c.status === "ERROR" || (typeof c.status === "number" && c.status >= 400);

  const beforeLCP = lcpTime ? calls.filter((c) => c.end <= lcpTime) : [];
  const afterLCP = lcpTime ? calls.filter((c) => c.end > lcpTime) : calls;
  const errors = calls.filter(isError);
  const bootstrapErrors = errors.filter((c) => !lcpTime || c.end <= lcpTime);
  const postLCPErrors = errors.filter((c) => lcpTime && c.end > lcpTime);
  const authCalls = calls.filter((c) => isAuthCall(c.url));
  const authBeforeLCP = lcpTime ? authCalls.filter((c) => c.end <= lcpTime) : [];

  const row = (c) => ({
    Type: c.type,
    Status: c.status,
    "Start (ms)": c.start,
    "End (ms)": c.end,
    "Duration (ms)": c.duration,
    ...(lcpTime ? { "Before LCP": c.end <= lcpTime ? "⚠️ yes" : "no" } : {}),
    URL: c.url?.length > 80 ? "..." + c.url.slice(-77) : c.url,
  });

  console.group(
    "%c📡 Fetch & XHR Timeline",
    "font-weight: bold; font-size: 14px;"
  );
  console.log("");

  if (lcpTime) {
    console.log(`%cLCP: ${lcpTime}ms`, "font-weight: bold;");
    console.log("");
  } else {
    console.warn("LCP timing not available — before/after LCP analysis skipped. Make sure the inject snippet is active and reload the page.");
    console.log("");
  }

  if (calls.length === 0) {
    console.log(
      "%c✅ No fetch or XHR calls detected during page load",
      "color: #22c55e; font-weight: bold;"
    );
    console.groupEnd();
    return;
  }

  // Summary
  console.log("%cSummary:", "font-weight: bold;");
  console.log(`   Total calls: ${calls.length}`);
  if (lcpTime) {
    console.log(
      `   ${beforeLCP.length > 0 ? "⚠️" : "✅"} Before LCP (critical path candidates): ${beforeLCP.length}`
    );
    console.log(
      `   ✅ After LCP (confirmed non-blocking): ${afterLCP.length}`
    );
  }
  if (bootstrapErrors.length > 0) {
    console.log(`   🔴 Bootstrap errors (before LCP): ${bootstrapErrors.length}`);
  }
  if (postLCPErrors.length > 0) {
    console.log(`   🔴 Errors after LCP: ${postLCPErrors.length}`);
  }
  if (authCalls.length > 0) {
    console.log(
      `   🔑 Auth-related calls: ${authCalls.length}` +
        (authBeforeLCP.length > 0 ? ` — ${authBeforeLCP.length} before LCP ⚠️` : "")
    );
  }

  // Case 1 — API calls blocking LCP
  if (beforeLCP.length > 0) {
    console.log("");
    console.group(
      "%c⚠️ Calls completing before LCP — investigate as critical path blockers",
      "color: #ef4444; font-weight: bold;"
    );
    console.log(
      "%cCalls completing before LCP are candidates for deferral, aggressive caching, or parallelization. If a framework waits for their response before rendering, each one adds directly to LCP.",
      "color: #6b7280;"
    );
    console.table(beforeLCP.map(row));
    console.groupEnd();
  }

  // Case 2 — Bootstrap errors (before LCP)
  if (bootstrapErrors.length > 0) {
    console.log("");
    console.group(
      "%c🔴 Bootstrap errors — failed calls before LCP",
      "color: #ef4444; font-weight: bold;"
    );
    console.log(
      "%cA failed auth check or config endpoint during bootstrap can add hundreds of ms before first paint — even when the error is caught silently.",
      "color: #6b7280;"
    );
    console.table(
      bootstrapErrors.map((c) => ({
        Type: c.type,
        Status: c.status,
        Error: c.error || "-",
        "Start (ms)": c.start,
        "Duration (ms)": c.duration,
        URL: c.url?.length > 80 ? "..." + c.url.slice(-77) : c.url,
      }))
    );
    console.groupEnd();
  }

  if (postLCPErrors.length > 0) {
    console.log("");
    console.group(
      "%c🔴 Failed calls after LCP",
      "color: #ef4444; font-weight: bold;"
    );
    console.table(
      postLCPErrors.map((c) => ({
        Type: c.type,
        Status: c.status,
        Error: c.error || "-",
        "Start (ms)": c.start,
        "Duration (ms)": c.duration,
        URL: c.url?.length > 80 ? "..." + c.url.slice(-77) : c.url,
      }))
    );
    console.groupEnd();
  }

  // Case 4 — Unnecessary auth checks
  if (authCalls.length > 0) {
    console.log("");
    console.group(
      "%c🔑 Auth-related calls — verify these are necessary",
      "color: #f59e0b; font-weight: bold;"
    );
    console.log(
      "%cAuth calls on pages where the user is known to be unauthenticated add latency without value. Cross-reference with your routing guards.",
      "color: #6b7280;"
    );
    console.table(authCalls.map(row));
    console.groupEnd();
  }

  // Case 3 — Complete network inventory
  console.log("");
  console.group("%c📋 Complete network inventory", "font-weight: bold;");
  console.log(
    "%cIncludes cross-origin calls invisible to the Network panel timing data (no Timing-Allow-Origin header).",
    "color: #6b7280;"
  );
  console.table(
    calls
      .sort((a, b) => a.start - b.start)
      .map((c) => ({
        ...row(c),
        Auth: isAuthCall(c.url) ? "🔑" : "-",
      }))
  );
  console.groupEnd();

  console.groupEnd();
})();

Understanding the Results

Summary section:

FieldMeaning
Total callsAll fetch and XHR calls captured from page start
Before LCPCalls that completed before LCP — these are candidates for critical path investigation
After LCPCalls confirmed not to have blocked LCP
Errors / failed callsCalls with HTTP 4xx/5xx status or network errors

Before LCP table:

Calls in this group completed before the LCP element was painted. They may have contributed to the rendering delay — particularly if a framework waits for their response before rendering content. Cross-reference with the LCP element: if it is rendered by a JavaScript framework, any call completing shortly before LCP is a strong candidate.

Failed calls table:

Failed calls during bootstrap are a common source of unexpected rendering delays. A framework resolver that catches an auth error and redirects, or a config endpoint that times out, can add hundreds of milliseconds before the page renders its first meaningful content.


How window.__perfCalls works

The inject snippet stores all captured calls in window.__perfCalls as an array. You can query it directly at any point from the console:

// All calls
console.table(window.__perfCalls)
 
// Only failed calls
window.__perfCalls.filter(c => c.status === 'ERROR' || c.status >= 400)
 
// Only calls that started in the first 2 seconds
window.__perfCalls.filter(c => c.start < 2000)

Each entry has the following shape:

{
  type: "fetch" | "xhr",
  url: "https://...",
  start: 312,        // ms from page start
  end: 748,          // ms from page start
  duration: 436,     // ms
  status: 200,       // HTTP status code, or "ERROR"
  error: "...",      // only present on network errors
}

Further Reading