why-did-you-fetch
v0.8.1
Published
Monkey-patches fetch/XHR to warn you in development about duplicate, redundant, and needlessly sequential network requests.
Maintainers
Readme
why-did-you-fetch
Monkey-patches fetch and XMLHttpRequest to warn you, in development, about network calls
your app almost certainly didn't mean to make: duplicate requests fired while an identical one
is already in flight, identical requests repeated moments after the last one finished, request
waterfalls that could have fired together, unthrottled bursts like a search box refetching on
every keystroke, and N+1-style storms where a list fetches each row's data individually. It's
the same idea as why-did-you-render — instrument something ubiquitous, stay silent
until there's something worth flagging, then print a clear, actionable console message with the
call site — applied to the network tab instead of the render tree.
Built with React apps as the primary target — these patterns show up hardest in component
trees where no single place owns the whole data-fetching picture — but it patches globalThis
directly, not React internals, so it works exactly the same way under Vue, Angular, Svelte, or no
framework at all. See Compatibility for the full picture.
Try the live demo → No install required — it runs the real published package in your browser against a mock network layer, with sample-project code for each detected pattern.

Real output from the live demo — the left panel is the demo's simulated network view, the right panel mirrors what actually prints to your browser's real console.
Why
Two components independently fetching the same resource, a useEffect firing twice under
StrictMode-like conditions, three independent lookups awaited one at a time instead of via
Promise.all, a search box firing a request on every keystroke with no debounce, a table of 50
rows each fetching its own record instead of one batched call — none of these throw, none of
them show up in a type error, and all of them are easy to miss in a network tab with a hundred
other requests in it. This library watches every fetch/XHR call as it happens and tells you
the moment one of these patterns shows up.
Install
npm install --save-dev why-did-you-fetchRequires Node.js ≥22 to install and build this package (that's about the toolchain, not about where the published code can run — see Compatibility for that).
Quick start
Call init() once, as early as possible in your app (entry point, root layout, etc.):
import { init } from 'why-did-you-fetch';
init();By default it's a no-op when process.env.NODE_ENV === 'production', so it's safe to leave the
call in unconditionally. It patches both fetch and XMLHttpRequest, and reports issues to the
console. That covers axios and most other HTTP clients in the browser, where they sit on
top of XHR (or fetch) — see Compatibility for the Node.js caveat.
Call the function init() returns to restore the originals (useful in tests, or with HMR):
const uninstall = init();
// ...
uninstall();React
A thin convenience hook is available at why-did-you-fetch/react — it calls init() on mount
and the returned uninstall() on unmount, so you don't have to manage that yourself:
import { useWhyDidYouFetch } from 'why-did-you-fetch/react';
function App() {
useWhyDidYouFetch(); // same options as init(); read once, at mount time
return <YourApp />;
}react is an optional peer dependency — only needed if you import this entry point.
Testing / CI
collectIssues() is the same as init(), but gives you the detected issues as a plain array
instead of (only) printing them, so a test can assert on it directly:
import { collectIssues } from 'why-did-you-fetch';
const { issues, uninstall } = collectIssues();
// ... exercise the page ...
expect(issues).toHaveLength(0); // fail the build on a real regression, not just warn about it
uninstall();See examples/cypress-ci-check for a full working example —
including a test that proves the assertion actually catches a real duplicate-fetch regression,
not just one that always passes.
What it detects
| Detector | Fires when | Confidence |
| -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| duplicate-inflight | The exact same request (method + URL + body) is issued again before the first call has settled. | High — this is almost always accidental. |
| duplicate-recent | The exact same request is issued again shortly (default 2s) after an identical call already finished. | High, but tune dedupeWindowMs for endpoints that are meant to be polled. |
| sequential-chain | Several requests (default 3+) fire back-to-back with almost no gap between one settling and the next starting — a request waterfall. | Heuristic. This flags the pattern of serialization, not a proven dependency problem — it's a prompt to go check whether Promise.all would work, not a claim that it definitely would. |
| rapid-calls | Several requests (default 5+) to the same path — each with a different query string — fire within a short window (default 1s). The classic shape is a search box refetching on every keystroke. | Heuristic. Disjoint from the two duplicate detectors above (it requires at least two distinct signatures in the burst), so it won't double-report an exact-duplicate storm. |
| n-plus-one | Several requests (default 5+) to the same route shape — id-like path segments collapsed, e.g. /api/users/:id — each to a different concrete URL, fire within a short window (default 500ms). A list rendering many rows that each fetch their own record instead of one batched request. | Heuristic. Requires distinct concrete URLs, so it won't double-report a rapid-calls burst (same path, varying query) or a duplicate-* storm (same URL repeated). |
Each issue is delivered with the call stack(s) involved, so you can jump straight to the
offending code — the default console reporter prints them as a collapsed, color-coded group.
A duplicate-recent report also names the previous response's Cache-Control freshness when
known, so you can tell "this could likely have been served from cache" apart from "the response
said not to cache it — dedupe the in-flight request instead."
Compatibility
init() patches whatever exists on the target object (globalThis by default). Where either
API is missing, that half of the patch is silently skipped — calling init() is always safe, it
just won't catch anything in an environment with neither.
| Environment | fetch | XMLHttpRequest |
| -------------------------------------------------- | ------------------------- | ---------------------- |
| Any browser (React, Vue, Svelte, Angular, vanilla) | ✅ | ✅ |
| React Native | ✅ | ✅ |
| Electron — renderer process | ✅ | ✅ |
| Electron — main process | ✅ (Node ≥18) | ❌ |
| Dedicated Web Worker | ✅ | ✅ |
| Service Worker | ✅ | ❌ |
| Node.js ≥18 | ✅ (native, via undici) | ❌ (never implemented) |
| Node.js <18 | ❌ (unless polyfilled) | ❌ |
| Deno | ✅ | ❌ |
| Bun | ✅ | ❌ |
A couple of specifics worth calling out:
- Node.js has no
XMLHttpRequest, ever — it's a browser/DOM API. On a Node server, only thefetchhalf ofinit()does anything. axioson the server bypasses both. Its default Node.js adapter ('http') talks tonode:http/node:httpsdirectly; the browser default ('xhr') sits on top of the API this library patches. Soaxioscalls are caught in the browser, not in a Node backend — same story for most other Node-native HTTP clients (got,superagent, etc.).- Service Workers get
fetchbut notXMLHttpRequest— it's excluded fromServiceWorkerGlobalScopeby spec, unlike regular (dedicated) Web Workers, which do have it.
Configuration
init({
enabled: process.env.NODE_ENV !== 'production', // default
patch: ['fetch', 'xhr'], // default: both
dedupeWindowMs: 2000, // "recent duplicate" window
chainGapMs: 10, // max gap between settle -> next start to count as "back-to-back"
chainMinLength: 3, // how many chained requests before it's reported
rapidCallWindowMs: 1000, // rolling window for the rapid-calls detector
rapidCallMinCount: 5, // how many varying-query calls within that window trigger it
nPlusOneWindowMs: 500, // rolling window for the n-plus-one detector
nPlusOneMinCount: 5, // how many distinct-URL calls to the same route shape trigger it
retainMs: 5000, // how long settled requests are remembered for comparison
maxInflightAgeMs: 60000, // stop tracking a request that never settles after this long
ignoreKeepalive: true, // skip fetch(url, { keepalive: true }) beacons/analytics calls
ignore: [
'/analytics', // substring match
/\/health-?check/i, // RegExp match
(url, method) => method === 'GET' && url.endsWith('.png'), // custom predicate
],
normalizeUrl: (url) => url.replace(/([?&])_=\d+/, ''), // strip cache-busting params before matching
normalizeBody: (body) => {
// Strip a volatile field before two otherwise-identical bodies are compared.
if (typeof body !== 'string') return body;
try {
const { traceId, ...rest } = JSON.parse(body);
return rest;
} catch {
return body;
}
},
onIssue: (issue) => {
// Fully replaces the console reporter — ship issues wherever you like.
myLogger.warn(issue.message, issue);
},
});See src/types.ts for the full Issue union and every option's doc comment.
Notes and caveats
- This is a development tool. It adds bookkeeping overhead to every network call; leave
enabledat its default so it's compiled out of / skipped in production. - A bug in the library's own instrumentation never breaks your real request. If something
throws while tracking a call — a bug here, or in a
ignore/normalizeUrl/normalizeBody/onIssuecallback you supplied — it's caught, logged viaconsole.errorwith a pointer to file an issue, and the actualfetch/XHRcall proceeds exactly as ifinit()had never run. - Data-fetching libraries that already dedupe (React Query, SWR, Apollo, RTK Query, ...)
won't produce
duplicate-*warnings for the requests they manage themselves, since they don't re-issue an in-flight request in the first place — that's the point of using them. This library is most useful for rawfetch/axiosusage, or for finding the requests that slip outside those libraries' cache keys. - The chain detector is intentionally conservative and intentionally honest about being a
heuristic. It cannot know whether request B actually needs request A's result — it only
reports the observable pattern of "these fired one after another with basically no gap." Use
your judgment before reaching for
Promise.all. - Request bodies from a
Requestobject (as opposed toinit.body) aren't fingerprinted, since reading them would mean consuming the stream before the realfetchgets to it — those calls are still tracked and matched by method + URL alone. - Other tools that also patch
fetch/XMLHttpRequest(an API mock library, a different interceptor, an APM agent) will each see whatever the previous one left behind. Callinit()as early as possible, before those, so it observes the real calls rather than another tool's already-transformed ones. - The React hook can miss its own children's very first fetches. It installs in a
useEffect, and React fires effects children-before-parents — so ifuseWhyDidYouFetch()is called in a root/layout component, fetches its descendants make during that first mount can happen before the patch is installed. Everything after that (re-renders, remounts, StrictMode's second pass) is caught normally. For guaranteed first-paint coverage, callinit()directly at your app's real entry point instead — seeexamples/vite-react; the hook remains the right choice when you don't control the entry point, like in Next.js — seeexamples/nextjs-app-router.
Troubleshooting
- No warnings are showing up. Confirm
init()actually ran (it no-ops silently whenNODE_ENV === 'production') and that the pattern you're testing matches a detector's definition above — e.g.duplicate-recentonly fires withindedupeWindowMsof the previous call settling, not any time later. - I'm seeing a duplicate warning I didn't expect under React StrictMode / Fast Refresh.
Dev-mode double-invocation and hot-reload can genuinely re-fire effects and, with them, real
duplicate requests — that's a legitimate case for the
ignoreoption, not a bug report. - A request I know is duplicated isn't being flagged. Check whether something else already
dedupes it (see above), whether its URL differs by a volatile query param (use
normalizeUrl), or whether it's aRequest-object call with a differing body (see the caveat above).
Examples
Live demo (see the top of this README) —
source at docs/demo.ts and docs/index.html.
Real, runnable integrations live in examples/ — a zero-build vanilla page, Vite +
React, Vue, Svelte, Angular, a Next.js App Router project (the one place useWhyDidYouFetch()'s
SSR-safety and effect-ordering behavior actually matters — see its README for why), and a Cypress
example showing collectIssues() failing a build on a real regression.
Contributing
See CONTRIBUTING.md.
Credit
why-did-you-render by Welldone Software came first and set the pattern this project
follows (see the intro above). Worth a star in its own right if this one was useful to you.
License
MIT © contributors
