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.