Axios Interceptors vs Wrapper Functions: A Decision Guide for Error Handling and Logging
Choose between Axios interceptors and wrapper functions for centralized error handling. Compare trade-offs, see a hybrid TypeScript implementation, and validate the boundary with a mock adapter.
14 Jun 2026, 23:50 UTC

The Decision: Where Should Cross-Cutting Logic Live?
When you build a JavaScript or TypeScript application around Axios, centralized error handling and logging are rarely optional. The question is not whether to centralize them, but where. Axios offers two supported mechanisms: interceptors registered on an instance, and wrapper functions that call the instance internally. Interceptors run automatically for every request and response passing through that instance. Wrapper functions are ordinary JavaScript functions—apiGet, apiPost—that invoke Axios and apply local logic. Choosing the wrong boundary leads to either scattered boilerplate or opaque error transformations that are hard to debug.
Comparing Interceptors and Wrapper Functions
| Dimension | Interceptors | Wrapper Functions |
|---|---|---|
| Execution model | Registered once; run for every request/response on the instance. | Called explicitly per endpoint; you control invocation. |
| Best suited for | Auth header injection, global logging, correlation IDs, uniform error transformation. | Payload shaping, endpoint-specific retries, custom headers, response normalization. |
| Debuggability | Can obscure the origin of a mutated response if multiple interceptors stack. | Explicit call sites make the flow easier to trace. |
| Bundle impact | Minimal per-call overhead; code lives with the instance. | Adds a function call, but unused wrappers can be tree-shaken by modern bundlers. |
| Reuse pattern | Global by design; hard to opt out for a single call. | Composable via higher-order wrappers to avoid duplication. |
Trade-offs in Practice
When Interceptors Obscure the Stack
Interceptors are powerful because they enforce policy without repetition. However, if three different interceptors all modify the same response—one unwraps a data envelope, another maps error codes, and a third logs metrics—the final shape reaching your component can be surprising. When a bug appears, you must trace through every interceptor in registration order. This is manageable for a single team, but risky in larger codebases where interceptors are added by different modules.
When Wrappers Duplicate Boilerplate
Wrapper functions give you fine-grained control. The downside is repetition: if twenty endpoints all need the same timeout, the same base error logging, and the same retry-on-429 behavior, writing that logic into twenty wrappers creates maintenance debt. The mitigation is composition. A higher-order wrapper can accept a base configuration and return a specialized function, keeping the shared logic in one place while preserving explicit call sites.
A Hybrid Implementation
Most production applications benefit from a hybrid boundary. Use interceptors for concerns that must apply to every HTTP call—attaching bearer tokens, generating request IDs, and logging latency. Use wrapper functions for concerns that are specific to an endpoint or a small group of endpoints—shaping request bodies, applying custom retry policies, or normalizing a non-standard response envelope.
The following TypeScript example illustrates that split. It assumes Axios 1.x, where axios.create returns an isolated instance and interceptor handlers receive an AxiosRequestConfig or AxiosResponse object.
// api/client.ts
import axios, { AxiosError, AxiosInstance, InternalAxiosRequestConfig } from 'axios';
export const apiClient: AxiosInstance = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL,
timeout: 10_000,
});
// Global request interceptor: auth and correlation
apiClient.interceptors.request.use(
(config: InternalAxiosRequestConfig) => {
const token = localStorage.getItem('authToken');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
config.headers['X-Request-ID'] = crypto.randomUUID();
return config;
},
(error: AxiosError) => Promise.reject(error)
);
// Global response interceptor: logging and error normalization
apiClient.interceptors.response.use(
(response) => {
const start = response.config.metadata?.startTime ?? Date.now();
const duration = Date.now() - start;
console.info(`[http] ${response.config.url} completed in ${duration}ms`);
return response;
},
(error: AxiosError) => {
console.error(`[http] ${error.config?.url} failed`, error.message);
// Transform to a predictable application error shape
return Promise.reject({
type: 'ApiError',
status: error.response?.status,
message: error.message,
});
}
);
// api/endpoints.ts
import { apiClient } from './client';
interface UserProfile {
id: string;
displayName: string;
}
// Endpoint-specific wrapper: payload shaping and response normalization
export async function fetchUserProfile(userId: string): Promise<UserProfile> {
const response = await apiClient.get<{ data: UserProfile }>(`/users/${userId}`, {
headers: { 'X-Endpoint-Variant': 'profile-v2' },
});
// Unwrap a non-standard envelope only for this endpoint
return response.data.data;
}
// Higher-order wrapper for shared endpoint logic
export function withRetry<T extends (...args: any[]) => Promise<any>>(fn: T, retries = 2): T {
return (async (...args: Parameters<T>) => {
for (let attempt = 0; attempt <= retries; attempt++) {
try {
return await fn(...args);
} catch (err) {
if (attempt === retries) throw err;
}
}
}) as T;
}
export const fetchOrders = withRetry(() =>
apiClient.get<{ items: unknown[] }>('/orders').then(r => r.data.items)
);
In this pattern, the interceptors guarantee that every request carries authentication and a traceable ID, and every failure is logged and reshaped before it reaches the UI. The wrapper fetchUserProfile handles an API that nests its payload under a data key, while withRetry composes retry behavior without duplicating loop logic across endpoints.
Validation with a Mock Adapter
Verifying that the boundary works requires inspecting the request as it leaves the client and the error as it arrives. A practical approach is to use a mock adapter in a test environment. The following snippet uses the adapter pattern supported by Axios to intercept requests without a network call. Run it with your test runner (for example, npx jest or npx vitest run) in a project where axios and axios-mock-adapter are installed as dev dependencies.
// tests/api-boundary.test.ts
import MockAdapter from 'axios-mock-adapter';
import { apiClient, fetchUserProfile } from '../api/endpoints';
describe('API boundary', () => {
it('injects auth and request-id headers via interceptor', async () => {
const mock = new MockAdapter(apiClient);
let capturedHeaders: Record<string, string> = {};
mock.onGet('/users/123').reply((config) => {
capturedHeaders = config.headers as any;
return [200, { data: { id: '123', displayName: 'Ada' } }];
});
localStorage.setItem('authToken', 'test-token');
await fetchUserProfile('123');
expect(capturedHeaders.Authorization).toBe('Bearer test-token');
expect(capturedHeaders['X-Request-ID']).toBeDefined();
mock.restore();
});
it('normalizes errors through the response interceptor', async () => {
const mock = new MockAdapter(apiClient);
mock.onGet('/users/404').reply(404, { message: 'Not Found' });
await expect(fetchUserProfile('404')).rejects.toMatchObject({
type: 'ApiError',
status: 404,
});
mock.restore();
});
});
The first test confirms that the interceptor ran before the request reached the adapter. The second confirms that the response interceptor transformed the raw Axios error into the application error shape. If either assertion fails, the boundary between global policy and endpoint logic has drifted.
Limitations and Verification
- Interceptor order matters. Request interceptors run in registration order; response interceptors run in reverse. Changing import order can silently change behavior.
- Error swallowing. A response interceptor that catches and returns a resolved promise will prevent downstream
.catchhandlers and wrapper-level retries from seeing the failure. - Tree-shaking caveats. Modern bundlers can drop unused wrapper functions, but importing an Axios instance that registers interceptors at module scope may retain that module if any part of it is referenced. Measure your bundle rather than assuming.
- Type safety. Interceptors in TypeScript receive broad
AxiosResponsetypes. Narrowing inside wrappers is often safer than narrowing inside interceptors.
To check the result in your own project, add a temporary console.log inside one request interceptor and one wrapper, then trigger a single API call. You should see exactly one interceptor log and one wrapper log. If the interceptor log is missing, the instance used by the wrapper is not the one you configured. If the wrapper log appears twice, a retry or duplicate registration is active.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.