Axios Interceptors in JavaScript: Complete Guide
A complete guide to Axios interceptors in JavaScript. Covers request interceptors for auth token injection and logging, response interceptors for error handling and token refresh, interceptor execution order, ejecting interceptors, and practical patterns including retry logic, loading indicators, error normalization, and request caching.
Axios interceptors let you run code before a request is sent or after a response is received. They are middleware for HTTP calls. Common uses include injecting authentication tokens, logging requests, refreshing expired tokens, normalizing errors, and adding retry logic. This guide covers every interceptor pattern you will encounter in production applications.
How Interceptors Work
Request Flow:
Your Code --> Request Interceptors --> Network --> Response Interceptors --> Your Code
Request Interceptors: modify config before sending
Response Interceptors: transform data or handle errors after receiving
Request Interceptors
A request interceptor receives the Axios config object and must return it (or a promise that resolves to it).
import axios from "axios";
const api = axios.create({
baseURL: "https://api.example.com/v2",
});
// Add auth token to every request
api.interceptors.request.use(
(config) => {
const token = localStorage.getItem("accessToken");
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => {
return Promise.reject(error);
}
);The first function handles the fulfilled case (config is ready). The second handles the rejected case (something failed before sending).
Response Interceptors
A response interceptor has two handlers: one for successful responses (2xx) and one for errors (non-2xx or network failures).
api.interceptors.response.use(
(response) => {
// Any 2xx status triggers this
return response;
},
(error) => {
// Any non-2xx status or network error triggers this
if (error.response) {
console.error(`HTTP ${error.response.status}: ${error.response.data.message}`);
} else if (error.request) {
console.error("Network error: no response received");
}
return Promise.reject(error);
}
);Interceptor Execution Order
| Phase | Order | Description |
|---|---|---|
| Request interceptors | LIFO (last added runs first) | Added later = runs earlier |
| Response interceptors | FIFO (first added runs first) | Added first = runs earliest |
| Error propagation | Chained | Rejected promise skips fulfilled handlers |
// Request interceptors execute in reverse order
api.interceptors.request.use((config) => {
console.log("Interceptor A"); // runs second
return config;
});
api.interceptors.request.use((config) => {
console.log("Interceptor B"); // runs first
return config;
});
// Response interceptors execute in order added
api.interceptors.response.use((res) => {
console.log("Interceptor C"); // runs first
return res;
});
api.interceptors.response.use((res) => {
console.log("Interceptor D"); // runs second
return res;
});
// Output: B, A, C, DToken Refresh Pattern
The most common production interceptor pattern. When the server returns 401, the interceptor refreshes the token and retries the original request.
let isRefreshing = false;
let failedQueue = [];
function processQueue(error, token = null) {
failedQueue.forEach(({ resolve, reject }) => {
if (error) {
reject(error);
} else {
resolve(token);
}
});
failedQueue = [];
}
api.interceptors.response.use(
(response) => response,
async (error) => {
const originalRequest = error.config;
// Only handle 401 errors, skip if already retrying
if (error.response?.status !== 401 || originalRequest._retry) {
return Promise.reject(error);
}
if (isRefreshing) {
// Queue this request until refresh completes
return new Promise((resolve, reject) => {
failedQueue.push({ resolve, reject });
}).then((token) => {
originalRequest.headers.Authorization = `Bearer ${token}`;
return api(originalRequest);
});
}
originalRequest._retry = true;
isRefreshing = true;
try {
const refreshToken = localStorage.getItem("refreshToken");
const { data } = await axios.post("https://api.example.com/auth/refresh", {
refreshToken,
});
const newToken = data.accessToken;
localStorage.setItem("accessToken", newToken);
localStorage.setItem("refreshToken", data.refreshToken);
api.defaults.headers.common.Authorization = `Bearer ${newToken}`;
processQueue(null, newToken);
originalRequest.headers.Authorization = `Bearer ${newToken}`;
return api(originalRequest);
} catch (refreshError) {
processQueue(refreshError, null);
localStorage.removeItem("accessToken");
localStorage.removeItem("refreshToken");
window.location.href = "/auth";
return Promise.reject(refreshError);
} finally {
isRefreshing = false;
}
}
);This pattern queues concurrent requests that fail with 401, refreshes the token once, then replays all queued requests with the new token. See how to use Axios in JavaScript complete guide for Axios fundamentals.
Retry Interceptor
function createRetryInterceptor(instance, maxRetries = 3) {
instance.interceptors.response.use(null, async (error) => {
const config = error.config;
// Only retry on network errors or 5xx
const shouldRetry =
!error.response || (error.response.status >= 500 && error.response.status < 600);
if (!shouldRetry || (config._retryCount || 0) >= maxRetries) {
return Promise.reject(error);
}
config._retryCount = (config._retryCount || 0) + 1;
// Exponential backoff: 1s, 2s, 4s
const delay = Math.pow(2, config._retryCount - 1) * 1000;
await new Promise((resolve) => setTimeout(resolve, delay));
console.log(`Retrying request (${config._retryCount}/${maxRetries}): ${config.url}`);
return instance(config);
});
}
createRetryInterceptor(api, 3);Retry Configuration Table
| Retry Attempt | Delay | Cumulative Wait | Status Codes Retried |
|---|---|---|---|
| 1 | 1 second | 1 second | 500, 502, 503, 504 + network errors |
| 2 | 2 seconds | 3 seconds | 500, 502, 503, 504 + network errors |
| 3 | 4 seconds | 7 seconds | 500, 502, 503, 504 + network errors |
Never retry 4xx errors (except 429 Rate Limit). Client errors indicate a problem with the request itself, not a transient server issue.
Loading State Interceptor
let activeRequests = 0;
function showLoader() {
document.getElementById("global-loader").classList.add("visible");
}
function hideLoader() {
document.getElementById("global-loader").classList.remove("visible");
}
api.interceptors.request.use((config) => {
if (!config.silent) {
activeRequests++;
showLoader();
}
return config;
});
api.interceptors.response.use(
(response) => {
if (!response.config.silent) {
activeRequests--;
if (activeRequests === 0) hideLoader();
}
return response;
},
(error) => {
if (!error.config?.silent) {
activeRequests--;
if (activeRequests === 0) hideLoader();
}
return Promise.reject(error);
}
);
// Usage: suppress loader for background polling
api.get("/api/notifications", { silent: true });The activeRequests counter ensures the loader stays visible until all pending requests complete. The silent flag lets specific requests skip the loader.
Error Normalization
class AppError {
constructor(message, code, details = null) {
this.message = message;
this.code = code;
this.details = details;
this.timestamp = new Date().toISOString();
}
}
api.interceptors.response.use(
(response) => response,
(error) => {
let normalized;
if (error.response) {
const { status, data } = error.response;
const messages = {
400: data.message || "Invalid request",
401: "Please log in to continue",
403: "You do not have permission for this action",
404: "The requested resource was not found",
409: "This resource already exists",
422: "Please check your input and try again",
429: "Too many requests. Please wait and try again",
500: "Something went wrong on our end",
};
normalized = new AppError(
messages[status] || `Server error (${status})`,
status,
data.errors || null
);
} else if (error.request) {
normalized = new AppError(
"Unable to reach the server. Check your connection.",
0,
null
);
} else {
normalized = new AppError(error.message, -1, null);
}
return Promise.reject(normalized);
}
);Every error that reaches your components is now an AppError with a user-friendly message, a status code, and optional validation details. No need to parse error shapes in every component.
Request Logging
api.interceptors.request.use((config) => {
config._startTime = Date.now();
console.log(`[API] ${config.method.toUpperCase()} ${config.url}`);
return config;
});
api.interceptors.response.use(
(response) => {
const duration = Date.now() - response.config._startTime;
console.log(
`[API] ${response.config.method.toUpperCase()} ${response.config.url} - ${response.status} (${duration}ms)`
);
return response;
},
(error) => {
if (error.config?._startTime) {
const duration = Date.now() - error.config._startTime;
const status = error.response?.status || "NETWORK_ERROR";
console.log(
`[API] ${error.config.method.toUpperCase()} ${error.config.url} - ${status} (${duration}ms)`
);
}
return Promise.reject(error);
}
);Ejecting Interceptors
// Store the interceptor ID
const authInterceptor = api.interceptors.request.use((config) => {
config.headers.Authorization = `Bearer ${getToken()}`;
return config;
});
// Remove it later
api.interceptors.request.eject(authInterceptor);Ejecting is useful when a user logs out (remove auth interceptor) or when you need to temporarily disable retry logic for a specific flow.
Composing Multiple Interceptors
function setupInterceptors(instance) {
// 1. Auth token injection (request)
instance.interceptors.request.use((config) => {
const token = localStorage.getItem("accessToken");
if (token) config.headers.Authorization = `Bearer ${token}`;
return config;
});
// 2. Request timing (request)
instance.interceptors.request.use((config) => {
config._startTime = Date.now();
return config;
});
// 3. Extract data (response) - unwrap response.data
instance.interceptors.response.use(
(response) => response,
(error) => Promise.reject(error)
);
// 4. Token refresh (response)
// ... token refresh interceptor from above
// 5. Error normalization (response - runs last)
// ... error normalization from above
}
const api = axios.create({ baseURL: "https://api.example.com" });
setupInterceptors(api);
export default api;Remember: request interceptors run LIFO (timing runs before auth), response interceptors run FIFO (data extraction runs before error normalization).
Testing With Interceptors
import axios from "axios";
function createTestClient() {
const client = axios.create({ baseURL: "http://localhost:3000" });
// Attach interceptors
setupInterceptors(client);
return client;
}
// In tests, mock the adapter
async function testRetryInterceptor() {
const client = createTestClient();
let callCount = 0;
// Override adapter to simulate failure then success
client.defaults.adapter = async (config) => {
callCount++;
if (callCount < 3) {
const error = new Error("Server Error");
error.response = { status: 500, data: {} };
error.config = config;
throw error;
}
return { data: { success: true }, status: 200, config };
};
const result = await client.get("/test");
console.assert(callCount === 3, "Should retry twice before succeeding");
console.assert(result.data.success === true, "Should return success");
}Rune AI
Key Insights
- Request interceptors run LIFO: The last interceptor added runs first, which affects the order of header modifications and logging
- Token refresh with queue: When a 401 occurs, queue all concurrent requests, refresh once, then replay them all with the new token
- Never retry client errors: Only retry network errors and 5xx responses; 4xx errors indicate the request itself is wrong
- Error normalization centralizes parsing: Convert every error into a consistent
AppErrorshape so components never parse raw Axios error objects - Eject interceptors for cleanup: Store the interceptor ID and call
eject()when the interceptor is no longer needed, such as after logout
Frequently Asked Questions
Can I use interceptors with the global axios instance?
Do interceptors affect all HTTP methods?
What happens if a request interceptor throws an error?
How do I skip an interceptor for a specific request?
Can interceptors modify the response data?
Conclusion
Axios interceptors provide a centralized place to manage cross-cutting concerns in HTTP communication. Request interceptors handle auth tokens, logging, and request timestamps. Response interceptors handle token refresh, retry logic, error normalization, and loading states. The key architectural insight is composing small, focused interceptors that each handle one concern, stacked in a predictable execution order (LIFO for requests, FIFO for responses). For the Axios fundamentals these interceptors build on, see how to use Axios in JavaScript complete guide. For how the event loop processes these async HTTP calls under the hood, see the JS event loop architecture complete guide. For the native Fetch alternative, see how to use the JS Fetch API complete tutorial.
More in this topic
OffscreenCanvas API in JS for UI Performance
Master the OffscreenCanvas API to offload rendering from the main thread. Covers worker-based 2D and WebGL rendering, animation loops inside workers, bitmap transfer, double buffering, chart rendering pipelines, image processing, and performance measurement strategies.
Advanced Web Workers for High Performance JS
Master Web Workers for truly parallel JavaScript execution. Covers dedicated and shared workers, structured cloning, transferable objects, SharedArrayBuffer with Atomics, worker pools, task scheduling, Comlink RPC patterns, module workers, and performance profiling strategies.
JavaScript Macros and Abstract Code Generation
Master JavaScript code generation techniques for compile-time and runtime metaprogramming. Covers AST manipulation, Babel plugin authorship, tagged template literals as macros, code generation pipelines, source-to-source transformation, compile-time evaluation, and safe eval alternatives.