Axios Interceptors: The Middleware Pattern That'll Save You Hours

If you're making HTTP requests in JavaScript, you're probably using Axios. And if you're using Axios but not using interceptors, you're writing the same code over and over again.
Let me show you what I mean.
Every single API request in your app probably needs:
An auth token in the header
Error handling for 401s (redirect to login)
Error handling for 500s (show a toast notification)
Loading state management
Request logging for debugging
Maybe retry logic for failed requests
Without interceptors, you're doing this:
async function getUser() {
try {
const token = localStorage.getItem('token');
const response = await axios.get('/api/user', {
headers: { Authorization: `Bearer ${token}` }
});
return response.data;
} catch (error) {
if (error.response?.status === 401) {
localStorage.removeItem('token');
window.location.href = '/login';
}
throw error;
}
}
async function updateProfile(data) {
try {
const token = localStorage.getItem('token');
const response = await axios.put('/api/profile', data, {
headers: { Authorization: `Bearer ${token}` }
});
return response.data;
} catch (error) {
if (error.response?.status === 401) {
localStorage.removeItem('token');
window.location.href = '/login';
}
throw error;
}
}
// ... copy-pasted 47 more times
See the problem? Same auth logic. Same error handling. Copy-pasted everywhere.
Interceptors fix this. They're like middleware for your HTTP requests – code that runs before every request and after every response.
What Are Axios Interceptors?
Think of interceptors as checkpoints. Every request passes through them on the way out, and every response passes through them on the way back.
Here's how it works: when you make an API call, the request interceptor runs first. It can modify the request – add headers, log it, cancel it, whatever you need. Then the request goes to the server.
When the response comes back, the response interceptor runs. It can transform the data, handle errors globally, retry failed requests, all before your code even sees the response.
Setting Up Interceptors
Here's the basic pattern:
// Request interceptor
axios.interceptors.request.use(
(config) => {
// Modify the request config before it's sent
console.log('Request sent:', config.url);
return config;
},
(error) => {
// Handle request errors
return Promise.reject(error);
}
);
// Response interceptor
axios.interceptors.response.use(
(response) => {
// Any status code 2xx triggers this
console.log('Response received:', response.status);
return response;
},
(error) => {
// Any status code outside 2xx triggers this
console.log('Response error:', error.message);
return Promise.reject(error);
}
);
That's it. Now every Axios request in your app goes through these interceptors.
Use Case 1: Auto-Injecting Auth Tokens
This is probably the most common use case. Instead of manually adding the auth header to every request:
// Before interceptors - manual token on every request
axios.get('/api/user', {
headers: { Authorization: `Bearer ${token}` }
});
axios.post('/api/posts', data, {
headers: { Authorization: `Bearer ${token}` }
});
// After interceptors - automatic
axios.interceptors.request.use((config) => {
const token = localStorage.getItem('token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
// Now just make requests normally
axios.get('/api/user');
axios.post('/api/posts', data);
Every request automatically gets the auth token. No copy-paste. No forgetting to add it.
Use Case 2: Global Error Handling
This one saved me so much time. Handle 401s (auth expired), 500s (server errors), and network errors in one place:
axios.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
// Token expired - redirect to login
localStorage.removeItem('token');
window.location.href = '/login';
} else if (error.response?.status === 500) {
// Server error - show notification
toast.error('Something went wrong. Please try again.');
} else if (!error.response) {
// Network error - no response from server
toast.error('Network error. Check your connection.');
}
return Promise.reject(error);
}
);
Now every API error in your entire app is handled consistently. Users get logged out on 401s, see error messages on 500s, and get network error alerts automatically.
Use Case 3: Request/Response Logging
Great for debugging. See every API call in the console:
axios.interceptors.request.use((config) => {
console.log(`➡️ \({config.method.toUpperCase()} \){config.url}`, config.data);
return config;
});
axios.interceptors.response.use(
(response) => {
console.log(`✅ \({response.config.method.toUpperCase()} \){response.config.url}`, response.data);
return response;
},
(error) => {
console.log(`❌ \({error.config?.method?.toUpperCase()} \){error.config?.url}`, error.message);
return Promise.reject(error);
}
);
Now you can see the entire request/response flow in the console without adding console.logs everywhere.
Use Case 4: Loading State Management
Show a loading spinner while requests are in progress:
let activeRequests = 0;
axios.interceptors.request.use((config) => {
activeRequests++;
if (activeRequests === 1) {
// Show loading spinner
document.getElementById('loading').style.display = 'block';
}
return config;
});
axios.interceptors.response.use(
(response) => {
activeRequests--;
if (activeRequests === 0) {
// Hide loading spinner
document.getElementById('loading').style.display = 'none';
}
return response;
},
(error) => {
activeRequests--;
if (activeRequests === 0) {
document.getElementById('loading').style.display = 'none';
}
return Promise.reject(error);
}
);
The loading spinner shows automatically for any request and hides when all requests finish.
Use Case 5: Retry Failed Requests
Automatically retry requests that fail due to network issues:
axios.interceptors.response.use(
(response) => response,
async (error) => {
const config = error.config;
// Don't retry if we've already retried
if (!config || config._retry) {
return Promise.reject(error);
}
// Only retry on network errors or 5xx server errors
if (!error.response || error.response.status >= 500) {
config._retry = true;
// Wait 1 second before retrying
await new Promise((resolve) => setTimeout(resolve, 1000));
// Retry the request
return axios(config);
}
return Promise.reject(error);
}
);
Now if a request fails due to a temporary network hiccup or server error, it automatically retries once after a second.
Use Case 6: Transform Response Data
Extract the data you actually need from the response:
// API returns: { data: { user: {...} }, meta: {...} }
// You want: { user: {...} }
axios.interceptors.response.use((response) => {
// If response has a 'data' property, unwrap it
if (response.data && response.data.data) {
response.data = response.data.data;
}
return response;
});
// Now this works:
const response = await axios.get('/api/user');
console.log(response.data.user); // Direct access, no extra .data
Your code stays clean because the interceptor handles the data unwrapping.
Advanced Pattern: Creating Axios Instances
Instead of adding interceptors to the global axios object, create custom instances with their own interceptors. This is useful when you have multiple APIs with different auth schemes:
// API for your main backend
const apiClient = axios.create({
baseURL: 'https://api.yourapp.com',
timeout: 10000,
});
apiClient.interceptors.request.use((config) => {
const token = localStorage.getItem('token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
// API for a third-party service
const externalAPI = axios.create({
baseURL: 'https://external-api.com',
timeout: 5000,
});
externalAPI.interceptors.request.use((config) => {
config.headers['X-API-Key'] = process.env.EXTERNAL_API_KEY;
return config;
});
// Use them separately
apiClient.get('/user'); // Gets Bearer token
externalAPI.get('/data'); // Gets API key
Each instance has its own interceptors. No conflicts, no mixing auth schemes.
Removing Interceptors
Sometimes you need to remove an interceptor. Save the interceptor ID when you add it:
const requestInterceptor = axios.interceptors.request.use(
(config) => {
// Interceptor logic
return config;
}
);
// Later, remove it
axios.interceptors.request.eject(requestInterceptor);
This is useful for temporarily disabling interceptors or cleaning up when a component unmounts.
Real-World Example: Auth Refresh Flow
Here's a complete example that handles token refresh when the access token expires:
let isRefreshing = false;
let failedQueue = [];
const processQueue = (error, token = null) => {
failedQueue.forEach((prom) => {
if (error) {
prom.reject(error);
} else {
prom.resolve(token);
}
});
failedQueue = [];
};
axios.interceptors.response.use(
(response) => response,
async (error) => {
const originalRequest = error.config;
if (error.response?.status === 401 && !originalRequest._retry) {
if (isRefreshing) {
// Another request is already refreshing the token
// Queue this request to retry after refresh completes
return new Promise((resolve, reject) => {
failedQueue.push({ resolve, reject });
})
.then((token) => {
originalRequest.headers.Authorization = `Bearer ${token}`;
return axios(originalRequest);
})
.catch((err) => Promise.reject(err));
}
originalRequest._retry = true;
isRefreshing = true;
try {
const refreshToken = localStorage.getItem('refreshToken');
const response = await axios.post('/auth/refresh', { refreshToken });
const newAccessToken = response.data.accessToken;
localStorage.setItem('token', newAccessToken);
// Update the failed request with new token
originalRequest.headers.Authorization = `Bearer ${newAccessToken}`;
// Process all queued requests
processQueue(null, newAccessToken);
// Retry the original request
return axios(originalRequest);
} catch (refreshError) {
processQueue(refreshError, null);
localStorage.removeItem('token');
localStorage.removeItem('refreshToken');
window.location.href = '/login';
return Promise.reject(refreshError);
} finally {
isRefreshing = false;
}
}
return Promise.reject(error);
}
);
This handles the complex case where multiple requests fail with 401 simultaneously. Instead of all of them trying to refresh the token, only the first one does. The others wait and retry with the new token.
Common Gotchas
1. Don't Mutate Original Config Objects
// Bad - mutates the original config
axios.interceptors.request.use((config) => {
config.headers.common['X-Custom'] = 'value';
return config;
});
// Good - modify config.headers directly
axios.interceptors.request.use((config) => {
config.headers['X-Custom'] = 'value';
return config;
});
2. Always Return Config or Response
// Bad - doesn't return anything
axios.interceptors.request.use((config) => {
console.log('Request:', config.url);
// Missing return!
});
// Good - always return
axios.interceptors.request.use((config) => {
console.log('Request:', config.url);
return config;
});
If you don't return, the request won't be sent.
3. Handle Both Success and Error Callbacks
// Bad - only handles success
axios.interceptors.request.use((config) => {
return config;
});
// Good - handles both
axios.interceptors.request.use(
(config) => {
return config;
},
(error) => {
return Promise.reject(error);
}
);
Always provide both callbacks, even if the error callback just rejects the promise.
4. Be Careful with Async Interceptors
// This works - async is fine
axios.interceptors.request.use(async (config) => {
const token = await getTokenFromSecureStorage();
config.headers.Authorization = `Bearer ${token}`;
return config;
});
// But be aware it makes ALL requests wait
// If getting the token is slow, every request is slow
Async interceptors work, but they block all requests. Keep them fast.
Practical Setup: Complete Production Example
Here's how I structure interceptors in production apps:
// src/api/interceptors.js
import axios from 'axios';
import { toast } from 'react-toastify';
// Create API client
const apiClient = axios.create({
baseURL: process.env.REACT_APP_API_URL,
timeout: 10000,
});
// Request interceptor - add auth token
apiClient.interceptors.request.use(
(config) => {
const token = localStorage.getItem('token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => {
return Promise.reject(error);
}
);
// Response interceptor - handle errors globally
apiClient.interceptors.response.use(
(response) => {
return response.data; // Unwrap data
},
(error) => {
// Network error
if (!error.response) {
toast.error('Network error. Please check your connection.');
return Promise.reject(error);
}
const { status } = error.response;
// Handle specific status codes
switch (status) {
case 401:
localStorage.removeItem('token');
window.location.href = '/login';
toast.error('Session expired. Please login again.');
break;
case 403:
toast.error('You don\'t have permission to do that.');
break;
case 404:
toast.error('Resource not found.');
break;
case 500:
toast.error('Server error. Please try again later.');
break;
default:
toast.error('Something went wrong.');
}
return Promise.reject(error);
}
);
export default apiClient;
Then use it throughout the app:
// src/services/userService.js
import apiClient from './api/interceptors';
export const getUser = () => apiClient.get('/user');
export const updateProfile = (data) => apiClient.put('/profile', data);
export const uploadAvatar = (file) => {
const formData = new FormData();
formData.append('avatar', file);
return apiClient.post('/avatar', formData);
};
Clean, consistent, no repetition.
TypeScript Support
If you're using TypeScript, you can type your interceptors:
import axios, { AxiosRequestConfig, AxiosResponse, AxiosError } from 'axios';
axios.interceptors.request.use(
(config: AxiosRequestConfig) => {
// config is typed
return config;
},
(error: AxiosError) => {
return Promise.reject(error);
}
);
axios.interceptors.response.use(
(response: AxiosResponse) => {
return response;
},
(error: AxiosError) => {
return Promise.reject(error);
}
);
TypeScript will catch errors like typos in config properties or incorrect return types.
When NOT to Use Interceptors
Interceptors are powerful, but don't use them for everything:
Don't use for request-specific logic: If only one endpoint needs special handling, do it in that request, not in an interceptor.
// Bad - interceptor for one-off logic
axios.interceptors.request.use((config) => {
if (config.url === '/special-endpoint') {
config.headers['X-Special'] = 'value';
}
return config;
});
// Good - handle it in the request
axios.get('/special-endpoint', {
headers: { 'X-Special': 'value' }
});
Don't use for component state: Interceptors run at the API layer. Don't try to update React state from an interceptor.
// Bad - trying to update component state
axios.interceptors.response.use((response) => {
setLoading(false); // This doesn't work - no access to component state
return response;
});
// Good - handle state in the component
const fetchData = async () => {
setLoading(true);
try {
const data = await axios.get('/data');
setData(data);
} finally {
setLoading(false);
}
};
Don't use for business logic: Keep business logic in services, not interceptors. Interceptors should handle cross-cutting concerns like auth, logging, and error handling.
The Bottom Line
Axios interceptors are middleware for your HTTP requests. They let you:
Write auth logic once instead of on every request
Handle errors globally instead of in every try-catch
Log requests automatically for debugging
Transform responses consistently
Manage loading states centrally
The key insight: if you're doing the same thing before or after every request, it belongs in an interceptor.
Start simple. Add a request interceptor for auth tokens. Add a response interceptor for error handling. You'll immediately see how much cleaner your code becomes.
Then explore the advanced patterns – custom instances, token refresh, retry logic – as you need them.
Using Axios interceptors in your projects? Have a clever pattern to share? Drop it in the comments.






