Overview
Axios interceptors are functions that run before a request is sent or after a response is received. They let a React application centralize cross-cutting HTTP behavior such as auth headers, error normalization, logging, request IDs, timeout handling, token refresh, and retry behavior.
Interceptors are powerful because they sit at the API client boundary. A component can call api.get("/profile"), while the Axios instance handles routine work around the request lifecycle.
They are also risky when used carelessly. Interceptors can hide important behavior, create infinite retry loops, leak credentials, swallow errors, duplicate logs, or register multiple times during hot reload and component renders. Good interceptor design is small, predictable, scoped to a specific Axios instance, and tested.
For interviews, this topic matters because it combines frontend architecture, authentication, async error handling, security, and production debugging. Strong candidates can explain what belongs in interceptors, what does not, and how to avoid making the API layer magical.
Core Concepts
What Axios Interceptors Are
Axios supports request interceptors and response interceptors.
Request interceptors run before Axios sends the request. They commonly:
- Attach auth headers.
- Add correlation IDs.
- Set tenant, locale, or version headers.
- Start request timing.
- Normalize config defaults.
- Skip behavior for specific requests.
Response interceptors run after Axios receives a response or error. They commonly:
- Return
response.data. - Normalize errors.
- Log failures.
- Handle
401 Unauthorized. - Retry selected failures.
- Trigger global notifications.
- Measure request duration.
Example:
import axios from "axios";
export const api = axios.create({
baseURL: "/api",
timeout: 10_000,
});
api.interceptors.request.use((config) => {
config.headers.set("x-client", "web");
return config;
});
api.interceptors.response.use(
(response) => response,
(error) => Promise.reject(error),
);
The interceptor chain is promise-based. If an interceptor throws or returns a rejected promise, the request moves to the error path.
Use Axios Instances Instead of Global Interceptors
Interceptors should usually be attached to an Axios instance created with axios.create.
Example:
export const internalApi = axios.create({
baseURL: import.meta.env.VITE_INTERNAL_API_URL,
timeout: 10_000,
});
export const publicApi = axios.create({
baseURL: "https://public.example.com",
timeout: 5_000,
});
Attach auth behavior only to the instance that calls the authenticated API:
internalApi.interceptors.request.use((config) => {
const token = authStore.getAccessToken();
if (token) {
config.headers.set("Authorization", `Bearer ${token}`);
}
return config;
});
This prevents accidentally sending private headers to third-party APIs. It also lets each service have different timeout, retry, logging, and error behavior.
Request Interceptors for Auth Headers
Auth headers are one of the most common uses for request interceptors.
Good auth interceptor behavior:
- Reads the access token close to request time.
- Adds the header only when a token exists.
- Uses a scoped Axios instance.
- Skips endpoints that must not receive the token if needed.
- Avoids logging the token.
Example:
internalApi.interceptors.request.use((config) => {
const token = tokenStore.getAccessToken();
if (token && config.headers) {
config.headers.set("Authorization", `Bearer ${token}`);
}
return config;
});
Avoid this pattern for multi-origin applications:
axios.defaults.headers.common.Authorization = `Bearer ${token}`;
Global defaults can send credentials to any request made through the default Axios object. A scoped instance is safer.
Request Metadata for Logging and Timing
Interceptors can attach metadata to measure duration and correlate logs.
Example:
declare module "axios" {
export interface InternalAxiosRequestConfig {
metadata?: {
startedAt: number;
requestId: string;
};
}
}
api.interceptors.request.use((config) => {
config.metadata = {
startedAt: performance.now(),
requestId: crypto.randomUUID(),
};
config.headers.set("x-request-id", config.metadata.requestId);
return config;
});
Then log response duration:
api.interceptors.response.use(
(response) => {
const startedAt = response.config.metadata?.startedAt;
const durationMs = startedAt ? performance.now() - startedAt : undefined;
logHttpSuccess({
method: response.config.method,
url: response.config.url,
status: response.status,
durationMs,
});
return response;
},
(error) => {
logHttpFailure(toLogEvent(error));
return Promise.reject(error);
},
);
Logging should redact sensitive data. Do not log Authorization, cookies, refresh tokens, passwords, or full request bodies that may contain personal data.
Response Interceptors for Data Unwrapping
Some teams use response interceptors to return response.data directly.
Example:
api.interceptors.response.use((response) => response.data);
This can reduce boilerplate, but it has trade-offs:
- Callers lose direct access to status, headers, and config unless the type system and API shape account for it.
- It can make the Axios instance behave differently from normal Axios.
- TypeScript declarations may need customization.
An alternative is to unwrap data in service functions:
export async function getProfile() {
const response = await api.get<UserProfile>("/me");
return response.data;
}
This is more explicit and often easier to type.
Response Interceptors for Error Normalization
Response error interceptors can convert Axios errors into application-specific errors.
Example:
export class HttpClientError extends Error {
constructor(
message: string,
public readonly status?: number,
public readonly code?: string,
public readonly details?: unknown,
) {
super(message);
}
}
api.interceptors.response.use(
(response) => response,
(error) => {
if (!axios.isAxiosError(error)) {
return Promise.reject(error);
}
const status = error.response?.status;
const data = error.response?.data as { message?: string } | undefined;
return Promise.reject(
new HttpClientError(
data?.message ?? error.message,
status,
error.code,
data,
),
);
},
);
This lets UI code handle predictable error types instead of checking error.response, error.request, error.code, and backend-specific response shapes everywhere.
Global Error Handling
Response interceptors are useful for global error handling, but not every error should become a global toast or redirect.
Good global handling examples:
- Redirect or publish an auth event for expired sessions.
- Show a network offline banner for network failures.
- Log unexpected
5xxresponses. - Capture correlation IDs for support.
Poor global handling examples:
- Showing a toast for every validation error.
- Redirecting on every
403, even when the current screen can explain the permission issue. - Swallowing errors so components think the request succeeded.
- Logging expected
404responses as critical incidents.
A good pattern is to normalize and publish global events, while still rejecting the error so feature code can decide local UX.
api.interceptors.response.use(
(response) => response,
(error) => {
const normalized = normalizeAxiosError(error);
if (normalized.status === 401) {
authEvents.emit("sessionExpired");
}
if (normalized.isNetworkError) {
networkEvents.emit("requestFailed");
}
return Promise.reject(normalized);
},
);
Retry Behavior in Interceptors
Retries can be implemented in an interceptor, but they must be constrained.
Retry only when:
- The request is safe or idempotent.
- The failure is likely transient.
- The retry count is limited.
- There is delay or backoff.
- The request has not been intentionally canceled.
- The server did not return a non-retryable status.
Example:
type RetryConfig = InternalAxiosRequestConfig & {
_retryCount?: number;
};
api.interceptors.response.use(
(response) => response,
async (error) => {
if (!axios.isAxiosError(error) || !error.config) {
return Promise.reject(error);
}
const config = error.config as RetryConfig;
const status = error.response?.status;
const method = config.method?.toUpperCase();
const retryCount = config._retryCount ?? 0;
const canRetryMethod = method === "GET" || method === "HEAD";
const canRetryStatus = !status || status === 408 || status === 429 || status >= 500;
if (retryCount >= 2 || !canRetryMethod || !canRetryStatus) {
return Promise.reject(error);
}
config._retryCount = retryCount + 1;
await delay(500 * 2 ** retryCount);
return api(config);
},
);
Do not blindly retry POST, PATCH, or DELETE unless the operation is idempotent or protected by idempotency keys.
Token Refresh and Retrying Original Requests
One advanced use case is refreshing an expired access token after a 401 and then retrying the original request.
The basic flow:
- Request fails with
401. - If it was not already retried, start or join a refresh-token request.
- Save the new access token.
- Update the original request header.
- Retry the original request once.
- If refresh fails, clear auth state and send the user to sign in.
Example:
let refreshPromise: Promise<string> | null = null;
api.interceptors.response.use(
(response) => response,
async (error) => {
if (!axios.isAxiosError(error) || !error.config) {
return Promise.reject(error);
}
const originalRequest = error.config as InternalAxiosRequestConfig & {
_retry?: boolean;
};
if (error.response?.status !== 401 || originalRequest._retry) {
return Promise.reject(error);
}
originalRequest._retry = true;
try {
refreshPromise ??= refreshAccessToken();
const newToken = await refreshPromise;
tokenStore.setAccessToken(newToken);
originalRequest.headers.set("Authorization", `Bearer ${newToken}`);
return api(originalRequest);
} catch (refreshError) {
authEvents.emit("sessionExpired");
return Promise.reject(refreshError);
} finally {
refreshPromise = null;
}
},
);
This prevents a burst of requests from triggering many refresh calls at once. Production implementations should also skip refresh logic for login and refresh endpoints themselves.
Avoiding Infinite Loops
Interceptor retry loops happen when a retried request goes through the same interceptor and meets the retry condition again.
Prevention techniques:
- Add
_retryor_retryCountmetadata. - Skip retry for auth endpoints.
- Limit retry count.
- Use separate Axios instance for token refresh if needed.
- Reject when refresh fails.
- Do not retry canceled requests.
- Do not retry validation or authorization failures.
Example skip:
if (originalRequest.url?.includes("/auth/refresh")) {
return Promise.reject(error);
}
Retry logic must be deliberately boring. Fancy hidden retry behavior is one of those things that looks elegant until production traffic makes it confusing.
Interceptor Registration and Cleanup
Interceptors should be registered once near the API client setup, not inside React components.
Bad pattern:
function ProfilePage() {
api.interceptors.response.use(handleResponse, handleError);
return <Profile />;
}
This registers a new interceptor on every render and can cause duplicated behavior.
If an interceptor must be temporary, store its ID and eject it:
const interceptorId = api.interceptors.response.use(handleResponse, handleError);
api.interceptors.response.eject(interceptorId);
Most app-level interceptors should be created once during module initialization.
Interceptor Execution Order
Request and response interceptors do not behave the same way.
In Axios:
- Request interceptors run in reverse order of registration.
- Response interceptors run in registration order.
- Each interceptor receives the result of the previous step.
- A thrown error moves the chain to the rejection path.
This matters when combining auth, logging, retry, and normalization. For example, if one response interceptor converts an Axios error to a custom error, a later interceptor that expects axios.isAxiosError(error) may no longer work.
Keep the chain intentionally ordered:
- Request: metadata, auth, final config validation.
- Response success: timing/logging, optional data unwrap.
- Response error: retry/token refresh, error normalization, global events/logging.
What Not to Put in Interceptors
Not everything belongs in an interceptor.
Avoid putting these in interceptors:
- Feature-specific UI messages.
- Component navigation decisions that depend on local context.
- Business rules for a single endpoint.
- Complex data transformations that belong in service functions.
- Validation error rendering.
- Broad mutation retry behavior.
- Silent fallback data that makes failures invisible.
Interceptors are best for cross-cutting transport concerns. Feature behavior should remain close to the feature.
Common Mistakes
Common mistakes include:
- Registering interceptors inside components.
- Using global Axios interceptors for all services.
- Sending auth headers to untrusted domains.
- Not ejecting temporary interceptors.
- Retrying all methods automatically.
- Retrying without a retry limit.
- Forgetting to mark retried requests.
- Running token refresh for the refresh endpoint itself.
- Swallowing errors instead of rejecting them.
- Logging secrets from headers or request bodies.
- Assuming interceptor order does not matter.
Best Practices
Best practices include:
- Use
axios.createfor scoped clients. - Register interceptors once.
- Keep interceptors small and composable.
- Attach auth headers at request time.
- Normalize errors into one app-level shape.
- Redact sensitive data in logs.
- Retry only safe or explicitly idempotent operations.
- Limit retries and use backoff.
- Use a refresh queue or shared refresh promise for token refresh.
- Let feature code handle local validation and business errors.
- Test interceptor behavior with success, failure, retry, cancellation, and auth-refresh cases.