Building a Robust Axios Client: Interceptors + AbortController for Cancellation
Learn how to centralize auth, error handling, and request cancellation in a single Axios instance using request/response interceptors and the standard AbortController API. A step‑by‑step example shows token injection, 401 retry logic, and clean‑up for a smooth web‑app experience.
26 Mar 2026, 14:31 UTC

Concrete Problem: Repeating Boilerplate Across Every API Call
In a typical single‑page application you’ll hit a backend many times: fetching user data, posting a form, searching a list. Each call often needs the same headers, error handling, and cancellation logic. Copy‑pasting that code everywhere is error‑prone, hard to maintain, and makes unit tests brittle.
Axios gives you two built‑in mechanisms to solve this: interceptors and the AbortController API. Together they let you build a single, shared API client that handles authentication, retries, logging, and cancellation in one place.
Thesis: Centralize Cross‑Cutting Concerns Once, Reuse Everywhere
By creating a dedicated Axios instance with request and response interceptors, and by wiring AbortController into that instance, you:
- Attach an auth token automatically.
- Normalize error objects for a single error‑handling strategy.
- Retry or redirect on specific HTTP status codes.
- Cancel stale or unnecessary requests on the client side.
1. Centralizing Logic with Interceptors
Request Interceptors
Request interceptors run before a request is sent. They can mutate the config object, add headers, or even abort the request early.
// apiClient.js
import axios from 'axios';
const api = axios.create({
baseURL: 'https://api.example.com',
timeout: 10000,
});
// Attach a Bearer token to every request
api.interceptors.request.use(
(config) => {
const token = localStorage.getItem('authToken');
if (token) {
config.headers['Authorization'] = `Bearer ${token}`;
}
return config;
},
(error) => Promise.reject(error)
);
Notice the config object is returned unchanged if no token is present. If you need to abort the request, you can simply throw an error or reject the promise.
Response Interceptors
Response interceptors run after a response (or error) is received. They can transform data, log, or handle specific status codes.
// Handle 401 by refreshing the token once
let isRefreshing = false;
let failedQueue = [];
const processQueue = (error, token = null) => {
failedQueue.forEach((prom) => {
if (error) {
prom.reject(error);
} else {
prom.resolve(token);
}
});
failedQueue = [];
};
api.interceptors.response.use(
(response) => response, // Pass through successful responses
async (error) => {
const originalRequest = error.config;
if (error.response && error.response.status === 401 && !originalRequest._retry) {
if (isRefreshing) {
// Queue subsequent 401 requests until token refresh completes
return new Promise((resolve, reject) => {
failedQueue.push({ resolve, reject });
}).then(() => api(originalRequest));
}
originalRequest._retry = true;
isRefreshing = true;
try {
const { data } = await axios.post('/auth/refresh'); // Adjust path
const newToken = data.token;
localStorage.setItem('authToken', newToken);
api.defaults.headers.common['Authorization'] = `Bearer ${newToken}`;
processQueue(null, newToken);
return api(originalRequest);
} catch (err) {
processQueue(err, null);
return Promise.reject(err);
} finally {
isRefreshing = false;
}
}
return Promise.reject(error);
}
);
Key points:
- Use
originalRequest._retryto avoid infinite retry loops. - Queue pending requests while a token refresh is in progress.
- Never swallow errors; rethrow if the interceptor does not handle the case.
2. Unified Error Handling
Axios distinguishes three error types:
error.response– server returned a non‑2xx status.error.request– request was made but no response received (network error).- Other – request setup failed.
By normalizing these in a response interceptor you can expose a single error shape to the rest of your app.
api.interceptors.response.use(
(response) => response,
(error) => {
const customError = {
message: error.message,
status: error.response ? error.response.status : null,
data: error.response ? error.response.data : null,
isNetwork: !!error.request && !error.response,
};
return Promise.reject(customError);
}
);
3. Cancellation with AbortController
Axios supports the standard AbortController from the Fetch API. Pass controller.signal in the request config and call controller.abort() to cancel.
// In a React component
import { useEffect, useRef } from 'react';
function Search({ query }) {
const controllerRef = useRef(null);
useEffect(() => {
if (!query) return;
controllerRef.current?.abort(); // Cancel previous request
const controller = new AbortController();
controllerRef.current = controller;
api
.get('/search', { params: { q: query }, signal: controller.signal })
.then((res) => console.log('Results:', res.data))
.catch((err) => {
if (axios.isCancel(err)) {
console.log('Request cancelled');
} else {
console.error('Search error', err);
}
});
return () => controller.abort();
}, [query]);
}
Important notes:
- The
signalproperty is available in Axios 1.x and later. - Cancelled requests reject with an error that
axios.isCancel(err)identifies. - Cancellation does not roll back server‑side work; it only stops the client from awaiting the response.
4. Practical Example: A Full API Client
Below is a minimal, annotated example that ties everything together. Replace placeholders with your actual endpoints and token logic.
// fullClient.js
import axios from 'axios';
export const api = axios.create({
baseURL: 'https://api.example.com',
timeout: 15000,
});
// 1. Request interceptor – add auth token
api.interceptors.request.use((config) => {
const token = localStorage.getItem('jwt');
if (token) config.headers['Authorization'] = `Bearer ${token}`;
return config;
});
// 2. Response interceptor – 401 handling & error normalization
let isRefreshing = false;
let refreshQueue = [];
const processQueue = (err, token = null) => {
refreshQueue.forEach((p) => {
if (err) p.reject(err); else p.resolve(token);
});
refreshQueue = [];
};
api.interceptors.response.use(
(res) => res,
async (err) => {
const { config, response } = err;
if (response && response.status === 401 && !config._retry) {
if (isRefreshing) {
return new Promise((resolve, reject) => {
refreshQueue.push({ resolve, reject });
}).then(() => api(config));
}
config._retry = true;
isRefreshing = true;
try {
const { data } = await axios.post('/auth/refresh');
const newToken = data.token;
localStorage.setItem('jwt', newToken);
api.defaults.headers.common['Authorization'] = `Bearer ${newToken}`;
processQueue(null, newToken);
return api(config);
} catch (refreshErr) {
processQueue(refreshErr, null);
return Promise.reject(refreshErr);
} finally {
isRefreshing = false;
}
}
const customErr = {
message: err.message,
status: response ? response.status : null,
data: response ? response.data : null,
isNetwork: !!err.request && !response,
};
return Promise.reject(customErr);
}
);
// 3. Exported helper to perform a cancellable GET
export function cancellableGet(url, params = {}, signal) {
return api.get(url, { params, signal });
}
5. Trade‑offs & Cleanup
- **Interceptor Order Matters** – Request interceptors execute in reverse registration order (LIFO). If you stack multiple, plan the order carefully.
- **Memory Leaks** – In long‑lived apps, failing to eject interceptors (via the handle returned by
.use) can accumulate handlers. Callapi.interceptors.request.eject(handle)in component unmounts if you register per‑component interceptors. - **AbortController Limitations** – Cancellation only stops the client; the server may still process the request. Use idempotent endpoints or server‑side cancellation if needed.
- **Version Compatibility** – AbortController support requires Axios 1.x+. If your codebase still uses 0.x, migrate to
signaland removeCancelTokenusage.
6. Actionable Steps for Your Project
- Pin a stable Axios version. Run
npm ls axiosto verify the installed major version. - Create a dedicated Axios instance. Keep baseURL, timeout, and interceptors in a single module.
- Implement request interceptor for auth. Store tokens in a secure place (e.g., HttpOnly cookies if possible).
- Set up a response interceptor. Handle 401, normalize errors, and optionally implement retry logic.
- Use AbortController for long‑running or user‑driven requests. Pass
signalin the config and abort on component unmount or when a new request supersedes the old. - Test the flow. Write unit tests that mock 401 responses, network failures, and abort scenarios to confirm interceptors behave as expected.
- **Document the pattern.**** Share the interceptor logic with teammates so everyone follows the same conventions.
By centralizing these concerns, you reduce duplication, make error handling consistent, and give your UI the ability to cancel redundant requests, all while keeping the codebase maintainable and future‑proof.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.