Handling Cross-Cutting Concerns in Axios HTTP Client
Managing cross-cutting concerns—such as authentication, standardized error handling, logging, request retries, and metrics collection—is essential for building robust and maintainable frontend or Node.js applications. In Axios, these shared responsibilities are best handled systematically using custom instances, interceptors, and modular plugins. Applying these best practices keeps network logic decoupled from UI components and business logic, reducing code duplication and ensuring consistent network behavior across an entire application.
1. Create Dedicated Axios Instances
Avoid using the global axios object directly. Mutating
global defaults leads to side effects and makes testing difficult.
Instead, create specialized instances with axios.create()
configured for specific APIs or environments.
import axios from 'axios';
export const apiClient = axios.create({
baseURL: process.env.API_BASE_URL || 'https://api.example.com/v1',
timeout: 10000,
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
});Using instances allows you to isolate configurations, custom headers, and interceptor chains between different microservices or third-party APIs.
2. Centralize Authentication via Request Interceptors
Injecting authorization tokens manually in every request is error-prone. Use request interceptors to append authentication tokens dynamically before the request is dispatched.
apiClient.interceptors.request.use(
(config) => {
const token = getAuthToken(); // Retrieve from secure storage or state
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => Promise.reject(error)
);3. Handle Token Refresh Seamlessly with Response Interceptors
Handle expired authentication tokens (HTTP 401) in a centralized response interceptor. This enables silent token refreshes and queues failed requests to be re-executed once a new token is obtained.
let isRefreshing = false;
let failedQueue = [];
const processQueue = (error, token = null) => {
failedQueue.forEach((prom) => {
if (error) {
prom.reject(error);
} else {
prom.resolve(token);
}
});
failedQueue = [];
};
apiClient.interceptors.response.use(
(response) => response,
async (error) => {
const originalRequest = error.config;
if (error.response?.status === 401 && !originalRequest._retry) {
if (isRefreshing) {
return new Promise((resolve, reject) => {
failedQueue.push({ resolve, reject });
})
.then((token) => {
originalRequest.headers.Authorization = `Bearer ${token}`;
return apiClient(originalRequest);
})
.catch((err) => Promise.reject(err));
}
originalRequest._retry = true;
isRefreshing = true;
try {
const newToken = await refreshAuthToken();
processQueue(null, newToken);
originalRequest.headers.Authorization = `Bearer ${newToken}`;
return apiClient(originalRequest);
} catch (refreshError) {
processQueue(refreshError, null);
logoutUser();
return Promise.reject(refreshError);
} finally {
isRefreshing = false;
}
}
return Promise.reject(error);
}
);4. Normalize and Transform API Errors
Raw HTTP errors often contain nested objects that vary across backend services. A centralized response interceptor can normalize errors into a consistent structure before they reach application services.
apiClient.interceptors.response.use(
(response) => response,
(error) => {
const normalizedError = {
status: error.response?.status || 500,
message: error.response?.data?.message || error.message || 'An unexpected error occurred',
code: error.response?.data?.code || 'UNKNOWN_ERROR',
details: error.response?.data?.errors || null,
};
return Promise.reject(normalizedError);
}
);5. Implement Distributed Tracing and Structured Logging
For observability across services, attach correlation IDs (such as
X-Correlation-ID or X-Request-ID) to every
outgoing request. Combine this with structured logging for debugging and
performance auditing.
import { v4 as uuidv4 } from 'uuid';
apiClient.interceptors.request.use((config) => {
const correlationId = uuidv4();
config.headers['X-Correlation-ID'] = correlationId;
config.metadata = { startTime: new Date() };
console.info(`[HTTP Request] ${config.method?.toUpperCase()} ${config.url}`, {
correlationId,
});
return config;
});
apiClient.interceptors.response.use(
(response) => {
const duration = new Date() - response.config.metadata.startTime;
console.info(`[HTTP Response] ${response.status} ${response.config.url} (${duration}ms)`);
return response;
},
(error) => {
if (error.config?.metadata) {
const duration = new Date() - error.config.metadata.startTime;
console.error(`[HTTP Error] ${error.response?.status || 'Network Error'} (${duration}ms)`);
}
return Promise.reject(error);
}
);6. Automate Retry Logic with Exponential Backoff
Transient network errors and HTTP 503 or
429 statuses should be retried automatically. Using an
established library like axios-retry prevents boilerplate
while enforcing exponential backoff and jitter.
import axiosRetry from 'axios-retry';
axiosRetry(apiClient, {
retries: 3,
retryDelay: axiosRetry.exponentialDelay,
retryCondition: (error) => {
return (
axiosRetry.isNetworkOrIdempotentRequestError(error) ||
error.response?.status === 429
);
},
});7. Manage Timeouts and Request Cancellation
Prevent dangling network calls and memory leaks by enforcing default
timeouts and supporting dynamic cancellation via standard
AbortController signals.
export const fetchUserData = (userId, signal) => {
return apiClient.get(`/users/${userId}`, {
signal, // Allows calling controller.abort() when component unmounts
timeout: 5000,
});
};8. Modularize Interceptors into Standalone Functions
To keep client configuration clean and testable, avoid writing inline interceptor functions inside the setup file. Separate each concern into its own module and attach them declaratively:
// interceptors/auth.js
export const applyAuthInterceptor = (instance) => {
instance.interceptors.request.use(/* logic */);
};
// interceptors/error.js
export const applyErrorInterceptor = (instance) => {
instance.interceptors.response.use(/* logic */);
};
// client.js
const client = axios.create({ /* options */ });
applyAuthInterceptor(client);
applyErrorInterceptor(client);Modularizing these functions makes individual cross-cutting concerns unit-testable in isolation and reusable across multiple Axios instances.