Axios Interceptors in JavaScript: Complete Guide

A complete guide to Axios interceptors in JavaScript. Covers request interceptors for auth token injection and logging, response interceptors for error handling and token refresh, interceptor execution order, ejecting interceptors, and practical patterns including retry logic, loading indicators, error normalization, and request caching.

JavaScriptintermediate
15 min read

Axios interceptors let you run code before a request is sent or after a response is received. They are middleware for HTTP calls. Common uses include injecting authentication tokens, logging requests, refreshing expired tokens, normalizing errors, and adding retry logic. This guide covers every interceptor pattern you will encounter in production applications.

How Interceptors Work

CodeCode
Request Flow:
  Your Code --> Request Interceptors --> Network --> Response Interceptors --> Your Code

Request Interceptors: modify config before sending
Response Interceptors: transform data or handle errors after receiving

Request Interceptors

A request interceptor receives the Axios config object and must return it (or a promise that resolves to it).

javascriptjavascript
import axios from "axios";
 
const api = axios.create({
  baseURL: "https://api.example.com/v2",
});
 
// Add auth token to every request
api.interceptors.request.use(
  (config) => {
    const token = localStorage.getItem("accessToken");
    if (token) {
      config.headers.Authorization = `Bearer ${token}`;
    }
    return config;
  },
  (error) => {
    return Promise.reject(error);
  }
);

The first function handles the fulfilled case (config is ready). The second handles the rejected case (something failed before sending).

Response Interceptors

A response interceptor has two handlers: one for successful responses (2xx) and one for errors (non-2xx or network failures).

javascriptjavascript
api.interceptors.response.use(
  (response) => {
    // Any 2xx status triggers this
    return response;
  },
  (error) => {
    // Any non-2xx status or network error triggers this
    if (error.response) {
      console.error(`HTTP ${error.response.status}: ${error.response.data.message}`);
    } else if (error.request) {
      console.error("Network error: no response received");
    }
    return Promise.reject(error);
  }
);

Interceptor Execution Order

PhaseOrderDescription
Request interceptorsLIFO (last added runs first)Added later = runs earlier
Response interceptorsFIFO (first added runs first)Added first = runs earliest
Error propagationChainedRejected promise skips fulfilled handlers
javascriptjavascript
// Request interceptors execute in reverse order
api.interceptors.request.use((config) => {
  console.log("Interceptor A"); // runs second
  return config;
});
 
api.interceptors.request.use((config) => {
  console.log("Interceptor B"); // runs first
  return config;
});
 
// Response interceptors execute in order added
api.interceptors.response.use((res) => {
  console.log("Interceptor C"); // runs first
  return res;
});
 
api.interceptors.response.use((res) => {
  console.log("Interceptor D"); // runs second
  return res;
});
 
// Output: B, A, C, D

Token Refresh Pattern

The most common production interceptor pattern. When the server returns 401, the interceptor refreshes the token and retries the original request.

javascriptjavascript
let isRefreshing = false;
let failedQueue = [];
 
function processQueue(error, token = null) {
  failedQueue.forEach(({ resolve, reject }) => {
    if (error) {
      reject(error);
    } else {
      resolve(token);
    }
  });
  failedQueue = [];
}
 
api.interceptors.response.use(
  (response) => response,
  async (error) => {
    const originalRequest = error.config;
 
    // Only handle 401 errors, skip if already retrying
    if (error.response?.status !== 401 || originalRequest._retry) {
      return Promise.reject(error);
    }
 
    if (isRefreshing) {
      // Queue this request until refresh completes
      return new Promise((resolve, reject) => {
        failedQueue.push({ resolve, reject });
      }).then((token) => {
        originalRequest.headers.Authorization = `Bearer ${token}`;
        return api(originalRequest);
      });
    }
 
    originalRequest._retry = true;
    isRefreshing = true;
 
    try {
      const refreshToken = localStorage.getItem("refreshToken");
      const { data } = await axios.post("https://api.example.com/auth/refresh", {
        refreshToken,
      });
 
      const newToken = data.accessToken;
      localStorage.setItem("accessToken", newToken);
      localStorage.setItem("refreshToken", data.refreshToken);
 
      api.defaults.headers.common.Authorization = `Bearer ${newToken}`;
      processQueue(null, newToken);
 
      originalRequest.headers.Authorization = `Bearer ${newToken}`;
      return api(originalRequest);
    } catch (refreshError) {
      processQueue(refreshError, null);
      localStorage.removeItem("accessToken");
      localStorage.removeItem("refreshToken");
      window.location.href = "/auth";
      return Promise.reject(refreshError);
    } finally {
      isRefreshing = false;
    }
  }
);

This pattern queues concurrent requests that fail with 401, refreshes the token once, then replays all queued requests with the new token. See how to use Axios in JavaScript complete guide for Axios fundamentals.

Retry Interceptor

javascriptjavascript
function createRetryInterceptor(instance, maxRetries = 3) {
  instance.interceptors.response.use(null, async (error) => {
    const config = error.config;
 
    // Only retry on network errors or 5xx
    const shouldRetry =
      !error.response || (error.response.status >= 500 && error.response.status < 600);
 
    if (!shouldRetry || (config._retryCount || 0) >= maxRetries) {
      return Promise.reject(error);
    }
 
    config._retryCount = (config._retryCount || 0) + 1;
 
    // Exponential backoff: 1s, 2s, 4s
    const delay = Math.pow(2, config._retryCount - 1) * 1000;
    await new Promise((resolve) => setTimeout(resolve, delay));
 
    console.log(`Retrying request (${config._retryCount}/${maxRetries}): ${config.url}`);
    return instance(config);
  });
}
 
createRetryInterceptor(api, 3);

Retry Configuration Table

Retry AttemptDelayCumulative WaitStatus Codes Retried
11 second1 second500, 502, 503, 504 + network errors
22 seconds3 seconds500, 502, 503, 504 + network errors
34 seconds7 seconds500, 502, 503, 504 + network errors

Never retry 4xx errors (except 429 Rate Limit). Client errors indicate a problem with the request itself, not a transient server issue.

Loading State Interceptor

javascriptjavascript
let activeRequests = 0;
 
function showLoader() {
  document.getElementById("global-loader").classList.add("visible");
}
 
function hideLoader() {
  document.getElementById("global-loader").classList.remove("visible");
}
 
api.interceptors.request.use((config) => {
  if (!config.silent) {
    activeRequests++;
    showLoader();
  }
  return config;
});
 
api.interceptors.response.use(
  (response) => {
    if (!response.config.silent) {
      activeRequests--;
      if (activeRequests === 0) hideLoader();
    }
    return response;
  },
  (error) => {
    if (!error.config?.silent) {
      activeRequests--;
      if (activeRequests === 0) hideLoader();
    }
    return Promise.reject(error);
  }
);
 
// Usage: suppress loader for background polling
api.get("/api/notifications", { silent: true });

The activeRequests counter ensures the loader stays visible until all pending requests complete. The silent flag lets specific requests skip the loader.

Error Normalization

javascriptjavascript
class AppError {
  constructor(message, code, details = null) {
    this.message = message;
    this.code = code;
    this.details = details;
    this.timestamp = new Date().toISOString();
  }
}
 
api.interceptors.response.use(
  (response) => response,
  (error) => {
    let normalized;
 
    if (error.response) {
      const { status, data } = error.response;
      const messages = {
        400: data.message || "Invalid request",
        401: "Please log in to continue",
        403: "You do not have permission for this action",
        404: "The requested resource was not found",
        409: "This resource already exists",
        422: "Please check your input and try again",
        429: "Too many requests. Please wait and try again",
        500: "Something went wrong on our end",
      };
 
      normalized = new AppError(
        messages[status] || `Server error (${status})`,
        status,
        data.errors || null
      );
    } else if (error.request) {
      normalized = new AppError(
        "Unable to reach the server. Check your connection.",
        0,
        null
      );
    } else {
      normalized = new AppError(error.message, -1, null);
    }
 
    return Promise.reject(normalized);
  }
);

Every error that reaches your components is now an AppError with a user-friendly message, a status code, and optional validation details. No need to parse error shapes in every component.

Request Logging

javascriptjavascript
api.interceptors.request.use((config) => {
  config._startTime = Date.now();
  console.log(`[API] ${config.method.toUpperCase()} ${config.url}`);
  return config;
});
 
api.interceptors.response.use(
  (response) => {
    const duration = Date.now() - response.config._startTime;
    console.log(
      `[API] ${response.config.method.toUpperCase()} ${response.config.url} - ${response.status} (${duration}ms)`
    );
    return response;
  },
  (error) => {
    if (error.config?._startTime) {
      const duration = Date.now() - error.config._startTime;
      const status = error.response?.status || "NETWORK_ERROR";
      console.log(
        `[API] ${error.config.method.toUpperCase()} ${error.config.url} - ${status} (${duration}ms)`
      );
    }
    return Promise.reject(error);
  }
);

Ejecting Interceptors

javascriptjavascript
// Store the interceptor ID
const authInterceptor = api.interceptors.request.use((config) => {
  config.headers.Authorization = `Bearer ${getToken()}`;
  return config;
});
 
// Remove it later
api.interceptors.request.eject(authInterceptor);

Ejecting is useful when a user logs out (remove auth interceptor) or when you need to temporarily disable retry logic for a specific flow.

Composing Multiple Interceptors

javascriptjavascript
function setupInterceptors(instance) {
  // 1. Auth token injection (request)
  instance.interceptors.request.use((config) => {
    const token = localStorage.getItem("accessToken");
    if (token) config.headers.Authorization = `Bearer ${token}`;
    return config;
  });
 
  // 2. Request timing (request)
  instance.interceptors.request.use((config) => {
    config._startTime = Date.now();
    return config;
  });
 
  // 3. Extract data (response) - unwrap response.data
  instance.interceptors.response.use(
    (response) => response,
    (error) => Promise.reject(error)
  );
 
  // 4. Token refresh (response)
  // ... token refresh interceptor from above
 
  // 5. Error normalization (response - runs last)
  // ... error normalization from above
}
 
const api = axios.create({ baseURL: "https://api.example.com" });
setupInterceptors(api);
 
export default api;

Remember: request interceptors run LIFO (timing runs before auth), response interceptors run FIFO (data extraction runs before error normalization).

Testing With Interceptors

javascriptjavascript
import axios from "axios";
 
function createTestClient() {
  const client = axios.create({ baseURL: "http://localhost:3000" });
 
  // Attach interceptors
  setupInterceptors(client);
 
  return client;
}
 
// In tests, mock the adapter
async function testRetryInterceptor() {
  const client = createTestClient();
  let callCount = 0;
 
  // Override adapter to simulate failure then success
  client.defaults.adapter = async (config) => {
    callCount++;
    if (callCount < 3) {
      const error = new Error("Server Error");
      error.response = { status: 500, data: {} };
      error.config = config;
      throw error;
    }
    return { data: { success: true }, status: 200, config };
  };
 
  const result = await client.get("/test");
  console.assert(callCount === 3, "Should retry twice before succeeding");
  console.assert(result.data.success === true, "Should return success");
}
Rune AI

Rune AI

Key Insights

  • Request interceptors run LIFO: The last interceptor added runs first, which affects the order of header modifications and logging
  • Token refresh with queue: When a 401 occurs, queue all concurrent requests, refresh once, then replay them all with the new token
  • Never retry client errors: Only retry network errors and 5xx responses; 4xx errors indicate the request itself is wrong
  • Error normalization centralizes parsing: Convert every error into a consistent AppError shape so components never parse raw Axios error objects
  • Eject interceptors for cleanup: Store the interceptor ID and call eject() when the interceptor is no longer needed, such as after logout
RunePowered by Rune AI

Frequently Asked Questions

Can I use interceptors with the global axios instance?

Yes, `axios.interceptors.request.use(...)` works on the global instance. However, creating a dedicated instance with `axios.create()` is better because it avoids polluting the global configuration shared by third-party libraries.

Do interceptors affect all HTTP methods?

Yes. Both request and response interceptors apply to GET, POST, PUT, DELETE, PATCH, and all other methods unless you add conditional logic inside the interceptor.

What happens if a request interceptor throws an error?

The request is never sent. The error propagates to the response interceptor error handler, then to the calling code's catch block.

How do I skip an interceptor for a specific request?

dd a custom flag to the config: `api.get("/url", { skipAuth: true })`. Then check for it in the interceptor: `if (config.skipAuth) return config;`.

Can interceptors modify the response data?

Yes. Return a modified response from the fulfilled handler: `return { ...response, data: transform(response.data) }`. This is commonly used to unwrap nested API response formats like `{ data: { users: [...] } }`.

Conclusion

Axios interceptors provide a centralized place to manage cross-cutting concerns in HTTP communication. Request interceptors handle auth tokens, logging, and request timestamps. Response interceptors handle token refresh, retry logic, error normalization, and loading states. The key architectural insight is composing small, focused interceptors that each handle one concern, stacked in a predictable execution order (LIFO for requests, FIFO for responses). For the Axios fundamentals these interceptors build on, see how to use Axios in JavaScript complete guide. For how the event loop processes these async HTTP calls under the hood, see the JS event loop architecture complete guide. For the native Fetch alternative, see how to use the JS Fetch API complete tutorial.