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:
| Field | Meaning |
|---|---|
| Total calls | All fetch and XHR calls captured from page start |
| Before LCP | Calls that completed before LCP — these are candidates for critical path investigation |
| After LCP | Calls confirmed not to have blocked LCP |
| Errors / failed calls | Calls 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
- Largest Contentful Paint (LCP) (opens in a new tab) | web.dev
- LCP Sub-Parts | Detailed LCP phase breakdown
- Fetch API (opens in a new tab) | MDN
- XMLHttpRequest (opens in a new tab) | MDN
- Resource Timing API (opens in a new tab) | MDN