DevTools Overrides
Fetch & XHR Timeline

Fetch & XHR timeline

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

(async () => {
  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;
  }

  // Chrome only exposes largest-contentful-paint entries through a PerformanceObserver,
  // so performance.getEntriesByType() returns [] here. Collect via a buffered observer.
  const lcpEntries = await new Promise((resolve) => {
    const entries = [];
    const obs = new PerformanceObserver((list) => entries.push(...list.getEntries()));
    obs.observe({ type: "largest-contentful-paint", buffered: true });
    setTimeout(() => { obs.disconnect(); resolve(entries); }, 100);
  });
  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