ESC

Type to search the knowledge base.

Fetch Interceptors

Wrap fetch so you can mutate the request, inspect the response, and chain interceptors, without monkey-patching window.fetch for the whole page.

intermediate6 min read
  • javascript
  • fetch
  • async
  • interview

window.fetch = function (input, init) { return window.fetch(input, init); } is an infinite loop, and even the version that saves const raw = window.fetch still patches the whole page: analytics, a third-party widget, and your test harness now send your Authorization header. Fetch interceptors wrap a function. createClient returns a fetch-like callable. Leave window.fetch alone.

The client runs request interceptors in array order, calls the real fetch, then runs response interceptors in reverse. That is the onion: the outermost interceptor sees the original request first and the final response last. AbortSignal on the init object must still abort the network call after you clone headers.

Key takeaways

  • Return a function. Do not assign window.fetch.
  • Request interceptors run in order. Response interceptors run reverse. Same stack, two passes.
  • Rebuild with new Request(old, { headers }) so method, body, credentials, and signal copy. Rebuilding from a URL string drops them.
  • Do not mutate the caller’s Request. new Request() headers use guard "request" (set works for allowed names). Clone into new Headers plus new Request(old, { headers }) anyway.
  • Peeking at a body requires request.clone() or response.clone(). Bodies are one-shot.

Wrap fetch, mutate request, inspect response, chain without monkey-patching window

createClient({ request, response }) is enough for one pair. Chaining is an array of those pairs. Inject the base fetch so tests can pass a stub and production can pass globalThis.fetch.bind(globalThis).

function createClient({
  fetch: baseFetch = globalThis.fetch.bind(globalThis),
  request,
  response,
  interceptors,
} = {}) {
  const stack = interceptors ?? [{ request, response }];

  return async function client(input, init) {
    let req = new Request(input, init);

    for (const interceptor of stack) {
      if (typeof interceptor.request !== 'function') continue;
      const next = await interceptor.request(req);
      if (next == null) continue;
      req = next instanceof Request ? next : new Request(next);
    }

    let res = await baseFetch(req);

    for (let i = stack.length - 1; i >= 0; i--) {
      const interceptor = stack[i];
      if (typeof interceptor.response !== 'function') continue;
      res = await interceptor.response(res, req);
    }

    return res;
  };
}

new Request(input, init) is the normalization step. Callers still write client('/api/me') or client(url, { method: 'POST', body, signal }). After this line you only pass a Request down the stack, so interceptors do not each re-implement the string | Request union. See Fetch API fundamentals for ok, one-shot bodies, and credentials.

Bind fetch before you store it. Unbound fetch can throw in some runtimes if this is undefined. You still call baseFetch(req), not window.fetch.

Request in order, response reverse

Two interceptors, A then B:

  1. A.request
  2. B.request
  3. baseFetch
  4. B.response
  5. A.response

A is the outer layer: logging, tracing, a request id. B is inner: auth header, Accept. On the way out, B can reject non-2xx before A logs the final status. Reverse is what makes “wrapper around wrapper” compose. Running responses in the same order as requests would let the inner logger see the response before auth had rewritten it.

function addRequestId(req) {
  const headers = new Headers(req.headers);
  headers.set('X-Request-Id', crypto.randomUUID());
  return new Request(req, { headers });
}

function addAuth(token) {
  return (req) => {
    const headers = new Headers(req.headers);
    headers.set('Authorization', 'Bearer ' + token);
    return new Request(req, { headers });
  };
}

async function rejectHttpError(res) {
  if (!res.ok) {
    const text = await res.text();
    throw new Error(res.status + ' ' + text);
  }
  return res;
}

const api = createClient({
  interceptors: [
    { request: addRequestId, response: async (res, req) => res },
    { request: addAuth(token), response: rejectHttpError },
  ],
});

await api('/api/me', { signal });

fetch fulfills on HTTP 404. A response interceptor is the right place to turn !res.ok into a throw if your app wants that. Do not expect the platform to throw.

AbortSignal pass-through

Request copies init.signal onto request.signal. new Request(oldRequest, { headers }) keeps that signal unless you override it. The failure is reconstructing from req.url:

// Drops method, body, credentials, and signal.
return new Request(req.url, { headers: { Authorization: 'Bearer x' } });

A search box that passed { signal } from AbortController now cannot cancel. Pair this with useFetch with abort: the hook owns the controller, the client must not eat the signal.

To add a timeout without dropping the caller’s signal:

function withTimeout(ms) {
  return (req) => {
    const extra = AbortSignal.timeout(ms);
    const signal = req.signal
      ? AbortSignal.any([req.signal, extra])
      : extra;
    return new Request(req, { signal });
  };
}

AbortSignal.any and AbortSignal.timeout are the platform combinators. If you cannot use them, document that the interceptor replaces the signal (and that you just broke the caller) or skip the timeout interceptor.

Pass signal into baseFetch by keeping it on the Request. fetch(request) uses request.signal. You do not pass init a second time.

Why window.fetch stays untouched

Approach What breaks
window.fetch = wrap(window.fetch) Every caller on the page, including ads and the next intern’s debug fetch. Recursion if the wrap calls fetch by name. Tests that stub global.fetch fight you.
createClient(...) Only code that imported api is wrapped. You can have a public client and an authenticated client.
Per-call wrappers Fine for one header. You will copy-paste the onion.

Monkey-patching is a last resort for a bookmarklet or a network logger you cannot inject. It is not the interview answer.

Retry belongs next to this client, not inside a response interceptor that cannot re-enter cleanly. Compose: createClient for headers and ok checks, then retry around () => api(url, init). Cancelable tasks are the other neighbor: abort is a signal on init, not a flag on the interceptor stack.

Footguns of fetch interceptors

  1. Mutating request.headers in place. new Request() uses guard "request": set succeeds for non-forbidden names and silently ignores forbidden ones. Network Response headers (and some service-worker requests) are the immutable ones. Still copy via new Headers(req.headers) and new Request(req, { headers }) so you do not mutate the caller’s object.
  2. Reading req.json() in a request interceptor. Consumes the body. The real fetch then throws. req.clone().json() if you must log it.
  3. Same on response. rejectHttpError that await res.text() on failure is fine (you are throwing). On success, return the original res so the caller can still json().
  4. Forgetting bind. Minor. Forgetting to pass baseFetch in tests: your client hits the network.
  5. Response interceptor returning undefined. The next layer and the caller get undefined. Return the Response.
  6. Auth on a cross-origin window.fetch patch. You just attached a bearer token to every URL on the page. Scope the client to your origin, or allowlist URLs inside addAuth.

Headers you set here are not CORS exemptions. Cross-origin still needs the server to allow the header. That is not an interceptor bug; it is CORS.

Interview answer (out loud)

“I would not assign window.fetch. I return a client from createClient that builds a Request, runs request interceptors in order, calls an injected fetch, then runs response interceptors in reverse. Each request interceptor clones with new Request(old, { headers }) so signal, method, and body survive. I never headers.set on the original Request. Bodies are one-shot, so logging uses clone. HTTP errors still fulfill; a response interceptor can throw on !ok. Abort is pass-through: if I rebuild from the URL string I dropped the caller’s AbortSignal.”

Further reading

Related guides