Isolate Axios Clients to Stop Shared-Config Leaks and Normalize Errors
Mutating global Axios defaults leaks headers, base URLs, and timeouts across unrelated modules. Learn how to isolate clients with axios.create() and normalize errors with interceptors.
08 Sept 2026, 08:17 UTC

The Global Defaults Trap
You set axios.defaults.baseURL for a new payments endpoint. Two sprints later, an unrelated search module starts querying the payments host, and a public status check carries a bearer token it should never see. The bug surfaces only when a token expires or a CORS policy rejects the request. The root cause is rarely the network; it is a single global Axios client that every module mutates.
When you call axios.get() directly, you are using the default instance. Its configuration lives on axios.defaults, a single mutable object shared across your entire bundle or Node process. Setting axios.defaults.baseURL for one service changes the target for every subsequent call. Adding an Authorization header to axios.defaults.headers.common attaches that token to public endpoints, logging calls, and third-party requests alike. The failure mode is delayed and confusing: a module that never touched auth suddenly sends credentials, or a timeout tuned for a slow upload breaks a fast health check.
Draw a Client Boundary with axios.create()
axios.create() returns a new Axios instance with its own defaults. It does not mutate the global client. The practical rule is one instance per backend service or bounded context. If your app talks to a billing API, a user directory, and an internal metrics collector, create three clients. Each owns its baseURL, timeout, and default headers. A change to the billing client cannot leak into the metrics client because they no longer share a configuration object.
Normalize Credentials and Errors at the Boundary
Once you have an isolated instance, interceptors become the natural place for cross-cutting concerns that belong to that specific service.
A request interceptor receives the request config and must return it (or a Promise that resolves to it). Use it to inject credentials that are specific to that service:
// billingClient.js
import axios from 'axios';
export const billingClient = axios.create({
baseURL: 'https://billing.internal/api',
timeout: 8000,
});
billingClient.interceptors.request.use((config) => {
const token = getBillingToken();
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
Assume getBillingToken() is your existing function that returns the current credential for that service.
A response interceptor receives the response on success and the error on failure. This is where you convert non-2xx responses into a predictable shape. Instead of every caller checking err.response.status and digging for a request ID, normalize once:
class AppError extends Error {
constructor(message, { status, requestId, cause }) {
super(message);
this.name = 'AppError';
this.status = status;
this.requestId = requestId;
this.cause = cause;
}
}
billingClient.interceptors.response.use(
(response) => response,
(error) => {
if (error.response) {
const { status, data, headers } = error.response;
const requestId = headers['x-request-id'] || data?.requestId;
return Promise.reject(
new AppError('Billing request failed', {
status,
requestId,
cause: error,
})
);
}
// Network error or timeout: preserve the original object
return Promise.reject(error);
}
);
Callers can now catch a single error type and rely on status and requestId without knowing Axios internals.
Trade-offs and Version Sensitivity
Interceptors add indirection. When a request fails, the stack trace points to the interceptor chain, not the original call site. Debugging requires temporarily logging the config inside the request interceptor or the raw error inside the response interceptor to confirm what was sent and received.
An unhandled rejection inside an interceptor can mask the original network failure. If your response interceptor throws because getBillingToken() is undefined, the caller sees that secondary error instead of the HTTP 401. Keep interceptors thin: inject values, normalize shapes, and rethrow. Avoid business logic or retry loops unless they are explicitly idempotent.
Interceptors run for every request on that instance. Per-call overrides—such as a longer timeout for one report—still require passing config explicitly to billingClient.get('/report', { timeout: 30000 }). Cross-instance concerns like global request logging do not belong on a single client; use a small factory function or shared wrapper instead.
Axios 1.x introduced a formal AxiosError type and a transitional options block that did not exist in 0.x. The create() and interceptor APIs remain broadly compatible, but the exact error shape you rely on can shift between major versions. Pin your Axios version in package.json and run a minimal request in Node to inspect the error object's constructor name and available fields before depending on them.
Checklist for a Predictable Client
- Search your codebase for direct mutations of
axios.defaultsand move them into an instance created withaxios.create(). - Group instances by backend service, not by UI feature, so configuration stays aligned with the remote boundary.
- Put service-specific auth in request interceptors and error normalization in response interceptors.
- Preserve the original error object when wrapping; never swallow status, headers, or config.
- Pin the Axios major version and add a smoke test that asserts your normalized error shape, including
statusand any request identifier.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.