npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

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.

Readme

why-did-you-fetch

npm version CI types npm downloads license

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.

The live demo's network and console panels, showing a real duplicate-in-flight warning for two GET requests to /api/users/42 fired 462ms apart with no gap, and the matching console message: "Duplicate in-flight request: GET /api/users/42 was requested again before the first call finished."

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-fetch

Requires 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 the fetch half of init() does anything.
  • axios on the server bypasses both. Its default Node.js adapter ('http') talks to node:http/node:https directly; the browser default ('xhr') sits on top of the API this library patches. So axios calls 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 fetch but not XMLHttpRequest — it's excluded from ServiceWorkerGlobalScope by 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 enabled at 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/ onIssue callback you supplied — it's caught, logged via console.error with a pointer to file an issue, and the actual fetch/XHR call proceeds exactly as if init() 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 raw fetch/axios usage, 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 Request object (as opposed to init.body) aren't fingerprinted, since reading them would mean consuming the stream before the real fetch gets 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. Call init() 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 if useWhyDidYouFetch() 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, call init() directly at your app's real entry point instead — see examples/vite-react; the hook remains the right choice when you don't control the entry point, like in Next.js — see examples/nextjs-app-router.

Troubleshooting

  • No warnings are showing up. Confirm init() actually ran (it no-ops silently when NODE_ENV === 'production') and that the pattern you're testing matches a detector's definition above — e.g. duplicate-recent only fires within dedupeWindowMs of 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 ignore option, 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 a Request-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