Axios Interceptors: Centralizing Auth and Error Handling Without Hidden Costs
Axios interceptors can centralize authentication, logging, and error handling across your app, but global misuse adds latency and obscures bugs. Here is how to use them deliberately.
09 Dec 2025, 10:49 UTC

The Problem: HTTP Concerns Scattered Across Call Sites
Every team that outgrows a handful of fetch calls eventually hits the same wall: authentication headers are added in a dozen places, a 401 response triggers three different redirect behaviors, and nobody agrees on what an error object should look like. Axios interceptors are the built-in answer to this fragmentation, but they are easy to misuse. Used deliberately, they turn scattered HTTP concerns into a single, testable pipeline. Used carelessly, they add hidden latency to every request and swallow the errors you need most.
The Pipeline Runs in Two Directions
Request interceptors execute in registration order before the request leaves. Response interceptors execute in reverse order after the response arrives. This means the last request interceptor to register sees the config after every earlier interceptor has modified it, and the first response interceptor to register sees the raw response before any later interceptor normalizes it. Since Axios v0.22, both request and response callbacks can return a Promise, so you can perform async work—like refreshing an expired token—without blocking the event loop.
If you register interceptors on the global axios import, every request in your application inherits them. For application code that is often acceptable. For shared libraries or multi-tenant dashboards, prefer axios.create() so each instance maintains an independent interceptor chain.
A Worked Example: Auth Header and Error Normalization
Here is a minimal instance that attaches a bearer token and normalizes HTTP errors into a typed shape. Run this in a Node.js script or browser module; no special permissions are required beyond your normal network access.
import axios from 'axios';
const api = axios.create({
baseURL: 'https://api.example.com/v1',
});
// Request: attach token if present
const authInterceptorId = api.interceptors.request.use(
(config) => {
const token = localStorage.getItem('authToken'); // or process.env.API_TOKEN in Node
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => Promise.reject(error)
);
// Response: normalize errors, preserve original for debugging
api.interceptors.response.use(
(response) => response,
(error) => {
if (error.response) {
// Server responded with a status outside 2xx
const normalized = new Error(error.response.data?.message || 'Request failed');
normalized.status = error.response.status;
normalized.response = error.response; // preserve original
return Promise.reject(normalized);
}
// Network error or timeout
return Promise.reject(error);
}
);
Expected checks: after firing a request, inspect the browser Network tab or Node debug output to confirm the Authorization header is present. Trigger a 404 and verify the rejected error carries a .status property and a .response object.
Isolating Config Mutations
Mutating config in a request interceptor affects every downstream interceptor and the final request. If you need isolation, clone the config first:
api.interceptors.request.use((config) => {
const isolated = { ...config, headers: { ...config.headers } };
isolated.headers['X-Request-ID'] = crypto.randomUUID();
return isolated;
});
Where Interceptors Bite Back
Async request interceptors that perform I/O—reading from IndexedDB, calling a token endpoint—add latency to every single request. Cache the result or use a dedicated refresh flow rather than blocking each call.
Response interceptors that catch errors must re-throw or return a rejected promise. If your onRejected handler returns a value, Axios treats the error as handled, and the calling code receives a successful response with undefined data. This is one of the most common interceptor bugs.
Dynamic Control and Verification
Every use() call returns an interceptor ID. You can remove it later with eject():
// Disable logging interceptor at runtime
const logId = api.interceptors.request.use((config) => {
console.log('Dispatching', config.url);
return config;
});
// Later...
api.interceptors.request.eject(logId);
This is useful for feature-flagged diagnostics or conditional auth strategies, but remember that ejecting an interceptor does not undo its past effects—it only prevents future execution.
To verify ordering, register two request interceptors that log sequence numbers and confirm they fire in registration order. To verify response order, log in the response interceptor and compare against the request sequence. To verify error handling, simulate a 401 using a tool like msw or nock and confirm your interceptor redirects or refreshes as intended.
An Actionable Closing
Interceptors are not a replacement for proper error boundaries or retry logic. They add a small amount of overhead per request, and deeply nested async chains can become hard to debug. Always check the Axios TypeScript definitions for InterceptorManager<InternalAxiosRequestConfig> and AxiosResponse to confirm signatures for your installed version.
- Scope interceptors to
axios.create()instances, not the global import, for reusable code. - Preserve the original
error.responsewhen normalizing errors. - Avoid blocking I/O in request interceptors; cache tokens instead.
- Re-throw in response error handlers unless you intentionally want to swallow the error.
- Use
eject()for dynamic behavior, and test the chain with mocked HTTP failures.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.