Axios in Practice: Instances, Interceptors, Error Handling and Cancellation

Key takeaways

Axios is a promise-based HTTP client for JavaScript. It works in both browser and Node.js, providing a clean API for making HTTP requests with interceptors, automatic JSON handling, and request cancellation.

Introduction

Axios is a promise-based HTTP client for the browser and Node.js. It provides a simple, clean API for making HTTP requests with features like interceptors, automatic JSON transformation, and request cancellation.

Why Axios?

Native Fetch:

fetch('https://api.example.com/users')
  .then(response => {
    if (!response.ok) throw new Error('HTTP error');
    return response.json();
  })
  .then(data => console.log(data))
  .catch(error => console.error(error));

With Axios:

axios.get('https://api.example.com/users')
  .then(response => console.log(response.data))
  .catch(error => console.error(error));

The two snippets are not equivalent, and the difference is the main argument for Axios. The fetch version has to check response.ok by hand, because fetch resolves successfully for a 404 or 500 and only rejects when no response arrives at all. Axios rejects for any status outside 2xx, parses JSON automatically into response.data, and has a timeout option; with fetch you add each of those yourself.

What Axios Adds Over fetch

Axios is one of the most widely used HTTP clients in the JavaScript ecosystem and has been around since 2014. Its value today, now that fetch exists in every browser and in Node.js 18+, comes down to a specific feature set:

  • Automatic JSON: request objects are serialized and JSON responses parsed without .json() calls
  • Status handling: non-2xx responses reject the promise with an AxiosError carrying the response
  • Interceptors: code that runs before every request and after every response — auth headers, token refresh, logging
  • Instances: a configured client (baseURL, headers, timeout) you reuse across the app
  • Timeouts and cancellation: a timeout option plus AbortController support
  • Upload/download progress: onUploadProgress, which fetch has no simple equivalent for
  • XSRF handling: reading a cookie and sending it as a header for same-origin requests
  • Isomorphic: the same API in the browser (XHR adapter) and Node.js (http adapter), with a fetch adapter option in recent 1.x releases

The cost is a dependency of a few KB gzipped and one more thing to keep updated. If you only need a couple of requests, fetch plus a ten-line wrapper is enough, and lightweight fetch wrappers such as ky offer a similar API on top of the platform. Where Axios still earns its place is in apps with many API calls sharing auth and error-handling policy — which is what interceptors are for.

Installation

# npm
npm install axios

# yarn
yarn add axios

# CDN (browser)
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>

Basic Usage

GET Request

const axios = require('axios');

// Simple GET
axios.get('https://api.example.com/users')
  .then(response => {
    console.log(response.data);
  })
  .catch(error => {
    console.error(error);
  });

// Async/await
async function getUsers() {
  try {
    const response = await axios.get('https://api.example.com/users');
    console.log(response.data);
  } catch (error) {
    console.error(error);
  }
}

// With query parameters
axios.get('https://api.example.com/users', {
  params: {
    page: 1,
    limit: 10,
  }
});
// Request: GET /users?page=1&limit=10

Prefer params over concatenating query strings: Axios URL-encodes the values, so a search term like "C++ & C#" does not break the URL or inject extra parameters. Arrays are serialized as ids[]=1&ids[]=2 by default; if your server expects ids=1&ids=2 or ids=1,2, configure paramsSerializer once on the instance. undefined values are dropped, which is usually what you want for optional filters, while null becomes an empty parameter.

POST Request

// POST with JSON body
axios.post('https://api.example.com/users', {
  name: 'Alice',
  email: '[email protected]',
})
  .then(response => console.log(response.data))
  .catch(error => console.error(error));

// With headers
axios.post('https://api.example.com/users', {
  name: 'Alice',
}, {
  headers: {
    'Content-Type': 'application/json',
    'Authorization': 'Bearer token123',
  }
});

The argument order differs by method, which is a frequent source of silent bugs. For post, put, and patch the signature is (url, data, config); for get and delete it is (url, config). Passing headers as the second argument of post sends them as the request body, and the server never sees the Authorization header. The explicit Content-Type: application/json is unnecessary — Axios sets it when data is a plain object.

PUT Request

// Update user
axios.put('https://api.example.com/users/123', {
  name: 'Alice Updated',
  email: '[email protected]',
})
  .then(response => console.log(response.data))
  .catch(error => console.error(error));

DELETE Request

// Delete user
axios.delete('https://api.example.com/users/123')
  .then(response => console.log('Deleted'))
  .catch(error => console.error(error));

// With data
axios.delete('https://api.example.com/users/123', {
  data: { reason: 'Account closed' }
});

Because delete takes a config object, a body has to go in config.data. HTTP allows a body on DELETE but gives it no defined meaning, and some proxies and servers drop it, so prefer query parameters or a POST to an action endpoint for anything important.

Axios Instance

// Create instance with default config
const api = axios.create({
  baseURL: 'https://api.example.com',
  timeout: 10000,
  headers: {
    'Content-Type': 'application/json',
  },
});

// Use instance
api.get('/users');                      // GET https://api.example.com/users
api.post('/users', { name: 'Alice' });  // POST https://api.example.com/users

// Multiple instances
const apiV1 = axios.create({ baseURL: 'https://api.example.com/v1' });
const apiV2 = axios.create({ baseURL: 'https://api.example.com/v2' });

An instance has its own defaults and its own interceptors, isolated from the global axios object. That isolation is the real reason to use instances: interceptors registered on global axios affect every library in the bundle that also uses the global instance, including third-party SDKs, which is how auth headers leak to unrelated hosts. Two gotchas with baseURL: joining is literal, so baseURL: 'https://api.example.com/v1' plus '/users' gives /v1/users, but a request URL that is itself absolute ('https://other.host/x') ignores baseURL entirely. If request paths can come from user input, that behavior can send your auth header to an attacker-controlled host; recent Axios versions add an allowAbsoluteUrls: false option to prevent it.

Setting 'Content-Type': 'application/json' as an instance default has a side effect worth knowing: in Axios 1.x, when the content type is JSON and you pass a FormData object, Axios converts the form to JSON instead of sending multipart. Leave Content-Type unset and let Axios choose per request.

Request Configuration

axios({
  method: 'post',
  url: '/users',
  baseURL: 'https://api.example.com',
  headers: {
    'Authorization': 'Bearer token123',
    'Content-Type': 'application/json',
  },
  params: {
    page: 1,
  },
  data: {
    name: 'Alice',
    email: '[email protected]',
  },
  timeout: 5000,
  withCredentials: true, // Send cookies
  responseType: 'json',  // 'arraybuffer', 'blob', 'document', 'json', 'text', 'stream'
  maxRedirects: 5,
  validateStatus: (status) => status >= 200 && status < 300,
});

Config merges in three levels — library defaults, instance defaults, then per-request options — with the most specific winning. validateStatus is the one to change for APIs that use status codes as data: validateStatus: (s) => s < 500 makes 4xx responses resolve normally so you can branch on response.status without a try/catch. withCredentials: true only matters for cross-origin browser requests, and it requires the server to respond with Access-Control-Allow-Credentials: true and a specific origin (not *); otherwise the browser blocks the response and Axios reports a generic Network Error. responseType: 'stream' works only in Node.js.

Response Schema

const response = await axios.get('/users/123');

console.log(response.data);       // Response body
console.log(response.status);     // 200
console.log(response.statusText); // "OK"
console.log(response.headers);    // Response headers
console.log(response.config);     // Request config
console.log(response.request);    // XMLHttpRequest (browser) / http.ClientRequest (Node)

response.headers has lowercase keys (response.headers['content-type']), and in the browser it only includes headers the server exposes through Access-Control-Expose-Headers for cross-origin requests — a custom X-Total-Count header visible in devtools can still be missing from JavaScript. Avoid logging response.config in production: it contains the request headers, including tokens.

Error Handling

try {
  const response = await axios.get('/users/123');
  console.log(response.data);
} catch (error) {
  if (error.response) {
    // Server responded with error status
    console.error('Status:', error.response.status);
    console.error('Data:', error.response.data);
    console.error('Headers:', error.response.headers);
  } else if (error.request) {
    // Request made but no response
    console.error('No response:', error.request);
  } else {
    // Error setting up request
    console.error('Error:', error.message);
  }
}

// Custom error handling
axios.get('/users/123')
  .catch(error => {
    if (error.response?.status === 404) {
      console.log('User not found');
    } else if (error.response?.status === 500) {
      console.log('Server error');
    } else {
      console.log('Unknown error');
    }
  });

The three branches map to three different failures and deserve different handling. error.response exists: the server answered with a non-2xx status, and error.response.data usually has the API’s error message. error.request exists without a response: the request went out but nothing usable came back — a timeout (error.code === 'ECONNABORTED' or 'ETIMEDOUT'), DNS or connection failure ('ENOTFOUND', 'ECONNREFUSED' in Node), or in the browser a CORS rejection, which JavaScript cannot distinguish from a network failure and which surfaces as Network Error with error.code === 'ERR_NETWORK'. Neither: the request was never sent, typically a bug in the config or an interceptor. axios.isAxiosError(error) tells you whether you are looking at an Axios error at all, which matters in TypeScript where catch gives you unknown. The first time you debug a “Network Error” that only happens in the browser, check the console for a CORS message before looking at the server — the server usually did respond.

Interceptors

Request Interceptors

// Add request interceptor
axios.interceptors.request.use(
  (config) => {
    // Modify config before request
    console.log('Request:', config.method, config.url);
    
    // Add auth token
    const token = localStorage.getItem('token');
    if (token) {
      config.headers.Authorization = `Bearer ${token}`;
    }
    
    return config;
  },
  (error) => {
    return Promise.reject(error);
  }
);

Response Interceptors

// Add response interceptor
axios.interceptors.response.use(
  (response) => {
    // Transform response data
    console.log('Response:', response.status);
    return response;
  },
  async (error) => {
    // Handle errors globally
    const originalRequest = error.config;
    
    if (error.response?.status === 401 && !originalRequest._retry) {
      originalRequest._retry = true;
      
      // Refresh token
      const newToken = await refreshAuthToken();
      axios.defaults.headers.common['Authorization'] = `Bearer ${newToken}`;
      
      return axios(originalRequest);
    }
    
    return Promise.reject(error);
  }
);

Interceptors form a chain: request interceptors run in reverse order of registration and response interceptors in registration order, and each must return the (possibly modified) config or response — forgetting return config makes every request fail with an error about reading properties of undefined. The response error handler can recover by returning a new promise, which is what the retry does.

This global example has a subtle bug worth understanding before copying it. Updating axios.defaults.headers.common affects future requests, but originalRequest already carries the old Authorization header in its own headers, so the retried request is sent with the expired token and fails again. Set the header on the request being retried (originalRequest.headers.Authorization = ...) or store the token where the request interceptor reads it, as the API client in section 11 does. The _retry flag is what prevents an infinite loop when the refreshed token is also rejected.

Remove Interceptors

const interceptor = axios.interceptors.request.use(config => config);

// Remove later
axios.interceptors.request.eject(interceptor);

Request Cancellation

const CancelToken = axios.CancelToken;
const source = CancelToken.source();

axios.get('/users', {
  cancelToken: source.token
})
  .catch(error => {
    if (axios.isCancel(error)) {
      console.log('Request canceled:', error.message);
    }
  });

// Cancel request
source.cancel('Operation canceled by user');

// AbortController (modern)
const controller = new AbortController();

axios.get('/users', {
  signal: controller.signal
});

// Cancel
controller.abort();

CancelToken is deprecated since Axios 0.22 and exists only for old code; use AbortController for anything new. A canceled request rejects with a CanceledError (axios.isCancel(error) is true, error.code === 'ERR_CANCELED'), so filter it out before showing error messages — otherwise users see “Request failed” every time they navigate away. The typical React use is aborting in an effect’s cleanup, so a slow response for a previous search term cannot overwrite the current one. A controller can only abort once; create a new one per request. For a hard overall deadline, signal: AbortSignal.timeout(5000) works as well as the timeout option and composes with other signals.

Concurrent Requests

// Multiple requests
const [users, posts, comments] = await Promise.all([
  axios.get('/users'),
  axios.get('/posts'),
  axios.get('/comments'),
]);

console.log(users.data);
console.log(posts.data);
console.log(comments.data);

// With axios.all (deprecated, use Promise.all)
axios.all([
  axios.get('/users'),
  axios.get('/posts'),
])
  .then(axios.spread((users, posts) => {
    console.log(users.data);
    console.log(posts.data);
  }));

Promise.all rejects as soon as any request fails and discards the other results, even the successful ones. When a page can render partially — the user list without the comment counts — use Promise.allSettled and handle each result’s status. Also resist firing hundreds of requests at once with Promise.all over an array of ids: browsers queue them anyway (about six connections per origin on HTTP/1.1), servers may rate-limit you, and a batch endpoint or a small concurrency limiter is kinder to both.

Form Data

// Multipart form data
const formData = new FormData();
formData.append('name', 'Alice');
formData.append('file', fileInput.files[0]);

axios.post('/upload', formData, {
  headers: {
    'Content-Type': 'multipart/form-data',
  },
  onUploadProgress: (progressEvent) => {
    const percentCompleted = Math.round(
      (progressEvent.loaded * 100) / progressEvent.total
    );
    console.log(`Upload: ${percentCompleted}%`);
  },
});

// URL-encoded form data
const params = new URLSearchParams();
params.append('name', 'Alice');
params.append('email', '[email protected]');

axios.post('/users', params, {
  headers: {
    'Content-Type': 'application/x-www-form-urlencoded',
  },
});

For multipart uploads the explicit Content-Type header is best left out. A multipart body needs a boundary parameter in the header (multipart/form-data; boundary=----...), and when you pass a FormData object Axios and the browser generate it together; hand-written headers without the boundary are the classic cause of server errors like Multipart: Boundary not found. progressEvent.total can be undefined when the size is unknown, so guard the percentage calculation. In Node.js, FormData is global since Node 18 and works with Axios 1.x; older code used the form-data package with formData.getHeaders(). For URLSearchParams, Axios sets the urlencoded content type automatically, so that header is also optional.

Real-World Example: API Client

// api.js
import axios from 'axios';

const api = axios.create({
  baseURL: process.env.REACT_APP_API_URL || 'https://api.example.com',
  timeout: 10000,
  headers: {
    'Content-Type': 'application/json',
  },
});

// Request interceptor
api.interceptors.request.use(
  (config) => {
    const token = localStorage.getItem('token');
    if (token) {
      config.headers.Authorization = `Bearer ${token}`;
    }
    return config;
  },
  (error) => Promise.reject(error)
);

// Response interceptor
api.interceptors.response.use(
  (response) => response,
  async (error) => {
    const originalRequest = error.config;
    
    // Handle 401 (unauthorized)
    if (error.response?.status === 401 && !originalRequest._retry) {
      originalRequest._retry = true;
      
      try {
        const { data } = await axios.post('/auth/refresh', {
          refreshToken: localStorage.getItem('refreshToken'),
        });
        
        localStorage.setItem('token', data.token);
        api.defaults.headers.common['Authorization'] = `Bearer ${data.token}`;
        
        return api(originalRequest);
      } catch (refreshError) {
        // Redirect to login
        window.location.href = '/login';
        return Promise.reject(refreshError);
      }
    }
    
    // Handle other errors
    if (error.response?.status === 500) {
      console.error('Server error:', error.response.data);
    }
    
    return Promise.reject(error);
  }
);

// API methods
export const usersAPI = {
  getAll: (params) => api.get('/users', { params }),
  getById: (id) => api.get(`/users/${id}`),
  create: (data) => api.post('/users', data),
  update: (id, data) => api.put(`/users/${id}`, data),
  delete: (id) => api.delete(`/users/${id}`),
};

export const postsAPI = {
  getAll: (params) => api.get('/posts', { params }),
  getById: (id) => api.get(`/posts/${id}`),
  create: (data) => api.post('/posts', data),
  update: (id, data) => api.put(`/posts/${id}`, data),
  delete: (id) => api.delete(`/posts/${id}`),
};

export default api;

This client shows the most common production setup and its three known weaknesses. Concurrent 401s: when a page fires five requests with an expired token, all five get 401 and all five call /auth/refresh; with rotating refresh tokens, the second refresh uses an already-invalidated token and logs the user out. The fix is to share one in-flight refresh promise — store it in a module variable, let every 401 handler await the same promise, and clear it when it settles. The refresh call itself uses bare axios.post('/auth/refresh'), which deliberately bypasses the instance’s interceptors (so a failing refresh cannot recurse into another refresh) but also bypasses baseURL: in the browser it goes to the page’s origin, not the API host. Give the refresh request the full URL or a separate instance with the same baseURL and no interceptors. Token storage: localStorage is readable by any script running on the page, so an XSS bug exposes both tokens; many teams keep the refresh token in an HttpOnly cookie instead and send withCredentials: true to the refresh endpoint.

One more detail: api.defaults.headers.common['Authorization'] = ... is redundant here, because the request interceptor reads the token from localStorage on every request, including the retried one. Having two sources of truth for the token is how clients end up sending stale headers.

TypeScript Support

import axios, { AxiosResponse } from 'axios';

interface User {
  id: number;
  name: string;
  email: string;
}

// Typed GET request
const response: AxiosResponse<User> = await axios.get<User>('/users/123');
const user: User = response.data;

// Typed POST request
const createUser = async (userData: Omit<User, 'id'>): Promise<User> => {
  const response = await axios.post<User>('/users', userData);
  return response.data;
};

// Generic API function
async function fetchData<T>(url: string): Promise<T> {
  const response = await axios.get<T>(url);
  return response.data;
}

const users = await fetchData<User[]>('/users');

The generic parameter is a type assertion, not validation: axios.get<User> tells the compiler what to expect, but whatever JSON the server sends becomes response.data unchecked. If the API renames email to emailAddress, the code still compiles and user.email is undefined at runtime. For external or unstable APIs, validate response.data with a schema library at the boundary (for example UserSchema.parse(response.data) with Zod) so contract changes fail loudly in one place. In catch blocks, narrow with axios.isAxiosError<ApiErrorBody>(error) to get typed access to error.response?.data.

Retries and timeouts: two defaults worth changing

Retrying only what is safe to retry

const axiosRetry = require('axios-retry');

axiosRetry(axios, {
  retries: 3,
  retryDelay: axiosRetry.exponentialDelay,
  retryCondition: (error) => {
    return axiosRetry.isNetworkOrIdempotentRequestError(error) ||
           error.response?.status === 429;
  },
});

isNetworkOrIdempotentRequestError deliberately retries only idempotent methods (GET, HEAD, OPTIONS, PUT, DELETE) on network errors and 5xx responses, because retrying a POST that actually reached the server can create a duplicate order or payment. Adding 429 to the condition, as here, applies to every method, so only do that if your POST endpoints accept an idempotency key or tolerate duplicates. For 429 and 503, respect the server’s Retry-After header rather than a fixed backoff. Note also that this example patches the global axios; pass your instance (axiosRetry(api, ...)) to keep retries scoped. Recent versions of axios-retry are ES modules, so require may return the function under .default.

Setting a timeout

const api = axios.create({
  timeout: 10000, // 10 seconds
});

// Or per request
axios.get('/users', { timeout: 5000 });

Axios has no timeout by default (timeout: 0), so a server that accepts the connection and never answers leaves the promise pending forever — in a Node service that can mean requests piling up behind a stuck dependency. Always set one on instances that talk to other services. When it fires, the error has code: 'ECONNABORTED' and a message like timeout of 5000ms exceeded.

Debugging

// Enable debug logging
axios.interceptors.request.use(config => {
  console.log('→', config.method?.toUpperCase(), config.url);
  return config;
});

axios.interceptors.response.use(response => {
  console.log('←', response.status, response.config.url);
  return response;
});

// Log full request/response
axios.get('/users')
  .then(response => {
    console.log('Request:', response.config);
    console.log('Response:', response);
  });

Where to go next

Resources:


Frequently Asked Questions (FAQ)

Q. Why does Axios reject on a 404 or 500 when fetch doesn’t?

A. Axios’s default validateStatus accepts only 2xx responses, so any other status rejects the promise with an AxiosError whose error.response contains the status and body. fetch resolves for any HTTP response and only rejects on network failures, so you must check res.ok yourself. If error.response is missing but error.request exists, the request was sent and no response came back, for example because of a timeout or a CORS block.