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.
- 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, andsignalcopy. 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 intonew Headersplusnew Request(old, { headers })anyway.- Peeking at a body requires
request.clone()orresponse.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:
A.requestB.requestbaseFetchB.responseA.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
- Mutating
request.headersin place.new Request()uses guard"request":setsucceeds for non-forbidden names and silently ignores forbidden ones. NetworkResponseheaders (and some service-worker requests) are the immutable ones. Still copy vianew Headers(req.headers)andnew Request(req, { headers })so you do not mutate the caller’s object. - Reading
req.json()in a request interceptor. Consumes the body. The realfetchthen throws.req.clone().json()if you must log it. - Same on
response.rejectHttpErrorthatawait res.text()on failure is fine (you are throwing). On success, return the originalresso the caller can stilljson(). - Forgetting
bind. Minor. Forgetting to passbaseFetchin tests: your client hits the network. - Response interceptor returning
undefined. The next layer and the caller getundefined. Return theResponse. - Auth on a cross-origin
window.fetchpatch. You just attached a bearer token to every URL on the page. Scope the client to your origin, or allowlist URLs insideaddAuth.
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 fromcreateClientthat builds aRequest, runs request interceptors in order, calls an injectedfetch, then runs response interceptors in reverse. Each request interceptor clones withnew Request(old, { headers })sosignal, method, and body survive. I neverheaders.seton the original Request. Bodies are one-shot, so logging usesclone. 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 on this site
- Fetch API fundamentals
- useFetch with AbortController
- AbortController
- Retry a promise N times
- Cancelable async tasks
- CORS for frontend
Related guides
- Analytics SDK Event QueueQueue analytics events, send them in series, retry the ones that fail, and flush on page hide without dropping the last click.
- Async Filter FunctionFilter an array with an async predicate: wait for every predicate, keep input order, emit a dense array, and decide fail-fast vs allSettled.
- Run Async Tasks in ParallelStart every task now, wait with Promise.all, and talk about the wall-clock vs series. Error handling and why unbounded parallel is a production bug.
- Run Async Tasks in SeriesExecute async functions one after another, collect results in order, and stop on first rejection. Reduce vs for-await, and when series is the point.
- Cancelable Async TasksWrap work so a caller can abort: AbortController, ignored stale results, and the difference between cancelling IO and ignoring a resolved promise.