Chaining Axios Interceptors for Request Pipelines
Axios interceptors allow developers to intercept HTTP requests and
responses before they are handled by then or
catch. By chaining multiple interceptors, you can construct
a modular, multi-stage processing pipeline where discrete tasks—such as
request tracing, authentication token injection, payload encryption, and
centralized error normalization—are executed in an isolated, sequential
manner. This guide explains how request and response interceptors
execute, how to structure multi-stage pipelines, and how to implement
them cleanly using custom Axios instances.
How Axios Interceptor Chaining Works
Axios maintains internal arrays for both request and response interceptors. When a request is dispatched, Axios constructs a promise chain:
- Request Interceptors: Execute in reverse order of registration (Last-In, First-Out). The interceptor added last runs first.
- HTTP Dispatch: The actual network call takes place.
- Response Interceptors: Execute in the exact order of registration (First-In, First-Out). The interceptor added first runs first.
[Request Interceptor 2] -> [Request Interceptor 1] -> [Network Request] -> [Response Interceptor 1] -> [Response Interceptor 2]
To control the execution flow precisely, create an isolated Axios instance rather than mutating the global Axios object.
Step 1: Create an Axios Instance
Create a dedicated client instance to ensure interceptors do not conflict across different API integrations:
import axios from 'axios';
const apiClient = axios.create({
baseURL: 'https://api.example.com/v1',
timeout: 10000,
headers: {
'Content-Type': 'application/json',
},
});Step 2: Define Modular Interceptor Functions
Keep each pipeline stage focused on a single responsibility.
Request Stages
// Stage A: Add Correlation ID / Tracing
export const traceInterceptor = (config) => {
config.headers['X-Correlation-ID'] = crypto.randomUUID();
config.metadata = { startTime: Date.now() };
return config;
};
// Stage B: Authentication Token Injection
export const authInterceptor = async (config) => {
const token = localStorage.getItem('access_token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
};
// Stage C: Payload Formatting / Sanitization
export const sanitizeInterceptor = (config) => {
if (config.data && typeof config.data === 'object') {
// Example: Strip empty string fields
Object.keys(config.data).forEach((key) => {
if (config.data[key] === '') {
delete config.data[key];
}
});
}
return config;
};Response Stages
// Stage A: Performance Logging
export const metricsInterceptor = (response) => {
const duration = Date.now() - response.config.metadata.startTime;
console.log(`[HTTP] ${response.config.url} completed in ${duration}ms`);
return response;
};
// Stage B: Data Normalization
export const unwrapDataInterceptor = (response) => {
// Return the data payload directly instead of the full Axios response object
return response.data;
};
// Stage C: Centralized Error Handler
export const errorNormalizationInterceptor = (error) => {
const normalizedError = {
status: error.response?.status || 500,
message: error.response?.data?.message || error.message || 'An unexpected error occurred.',
timestamp: new Date().toISOString(),
};
return Promise.reject(normalizedError);
};Step 3: Register and Order the Pipeline
Because request interceptors execute in reverse registration order, attach them from the innermost (closest to network) to outermost (first to run).
// REQUEST PIPELINE
// Desired execution order: traceInterceptor -> authInterceptor -> sanitizeInterceptor
apiClient.interceptors.request.use(sanitizeInterceptor); // Runs 3rd
apiClient.interceptors.request.use(authInterceptor); // Runs 2nd
apiClient.interceptors.request.use(traceInterceptor); // Runs 1st
// RESPONSE PIPELINE
// Desired execution order: metricsInterceptor -> unwrapDataInterceptor
apiClient.interceptors.response.use(
metricsInterceptor,
errorNormalizationInterceptor
); // Runs 1st
apiClient.interceptors.response.use(
unwrapDataInterceptor,
errorNormalizationInterceptor
); // Runs 2ndStep 4: Manage and Eject Interceptors Dynamically
When implementing conditional stages (such as temporary retry logic or debug logging), store the interceptor reference ID to remove it from the pipeline when no longer needed:
// Register a debug interceptor
const debugInterceptorId = apiClient.interceptors.request.use((config) => {
console.debug('Outgoing payload:', config.data);
return config;
});
// Eject the interceptor when done
apiClient.interceptors.request.eject(debugInterceptorId);Best Practices for Multi-Stage Pipelines
- Always Return
configorresponse: Every successful interceptor must return the modified or originalconfig/responseobject. Failing to return it breaks the promise chain. - Always Reject Errors: Always return
Promise.reject(error)within error handlers to allow downstream application logic to handle failures. - Avoid Heavy Compute in Request Stages: Request interceptors run synchronously by default or delay the request when asynchronous. Keep async operations (like refreshing tokens) isolated to stages where they are strictly required.