Previous Article
Regex Explained, and Why Email Validation Is Not a Regex Problem
26 April 2023
Axios is a promise-based HTTP client for the browser and Node. Its most important configuration option is also its most misunderstood: the default timeout is 0, meaning no timeout at all. A request to an unresponsive server hangs indefinitely, and users see a spinner that never stops.
This guide covers timeouts properly, plus cancellation, retries and when fetch is the better choice.
import axios from 'axios';
// GET
const { data } = await axios.get('https://api.example.com/orders');
// POST — body is serialised to JSON automatically
const { data } = await axios.post('https://api.example.com/orders', {
productId: 42,
quantity: 2,
});
// With config
const res = await axios.get('https://api.example.com/orders', {
params: { status: 'paid', page: 2 }, // → ?status=paid&page=2
headers: { Authorization: `Bearer ${token}` },
timeout: 5000,
});
Two conveniences over fetch: the body is JSON-serialised and parsed for you, and params handles URL encoding so you are not building query strings by hand.
// Per request
await axios.get('/api/slow', { timeout: 5000 });
// Globally
axios.defaults.timeout = 10_000;
// Per instance — the cleanest approach
const api = axios.create({
baseURL: 'https://api.example.com',
timeout: 8000,
});
This is the part worth knowing. Axios's timeout measures the time until a response arrives. It does not separately bound:
timeout.For a genuine end-to-end bound, combine the timeout with an abort signal:
async function fetchWithHardLimit(url, ms = 5000) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), ms);
try {
return await axios.get(url, {
signal: controller.signal,
timeout: ms, // belt and braces
});
} finally {
clearTimeout(timer); // always clear, or the timer leaks
}
}
A timeout rejects with a specific code, which you need in order to distinguish it from other network failures:
try {
const res = await api.get('/orders');
} catch (err) {
if (err.code === 'ECONNABORTED') {
show('The request took too long. Please try again.');
} else if (axios.isCancel(err)) {
return; // we cancelled it deliberately — not an error
} else if (err.response) {
// Server responded with 4xx or 5xx
show(`Server error ${err.response.status}`);
} else if (err.request) {
// Request sent, no response — offline, DNS failure, CORS
show('Could not reach the server. Check your connection.');
} else {
show('Something went wrong building the request.');
}
}
That three-way split — err.response, err.request, neither — is the Axios error shape, and it is genuinely more useful than fetch's, where a 500 is a *successful* promise you have to check manually.
CancelToken is deprecated. Use AbortController, the same standard API fetch uses:
const controller = new AbortController();
api.get('/search', {
params: { q: term },
signal: controller.signal,
});
// Later — a newer search supersedes this one
controller.abort();
The classic use is a type-ahead, where cancelling prevents an older response overwriting a newer one:
let controller = null;
async function search(term) {
controller?.abort(); // cancel the previous request
controller = new AbortController();
try {
const { data } = await api.get('/search', {
params: { q: term },
signal: controller.signal,
});
render(data);
} catch (err) {
if (!axios.isCancel(err)) throw err;
}
}
In React, abort on unmount so a resolved request does not try to set state on a component that no longer exists:
useEffect(() => {
const controller = new AbortController();
api.get('/profile', { signal: controller.signal })
.then(res => setProfile(res.data))
.catch(err => { if (!axios.isCancel(err)) setError(err); });
return () => controller.abort();
}, []);
This is where Axios earns its place over fetch. Configure once, apply everywhere:
const api = axios.create({
baseURL: import.meta.env.VITE_API_URL,
timeout: 8000,
headers: { 'Content-Type': 'application/json' },
});
// Attach the token to every request
api.interceptors.request.use((config) => {
const token = localStorage.getItem('token');
if (token) config.headers.Authorization = `Bearer ${token}`;
return config;
});
// Handle 401 globally
api.interceptors.response.use(
(response) => response,
async (error) => {
if (error.response?.status === 401 && !error.config._retried) {
error.config._retried = true; // guard against infinite loops
await refreshToken();
return api.request(error.config); // replay the original request
}
return Promise.reject(error);
}
);
The _retried flag is essential. Without it, a refresh that itself returns 401 retries forever.
Retry transient failures — never retry a 400, which will fail identically every time:
async function requestWithRetry(config, attempts = 3) {
for (let i = 0; i < attempts; i++) {
try {
return await api.request(config);
} catch (err) {
const status = err.response?.status;
const retryable = err.code === 'ECONNABORTED'
|| !err.response // network failure
|| status === 429
|| (status >= 500 && status < 600);
if (!retryable || i === attempts - 1) throw err;
// Honour Retry-After when the server sends it.
const retryAfter = Number(err.response?.headers?.['retry-after']) * 1000;
const backoff = retryAfter || Math.min(1000 * 2 ** i, 10_000);
await new Promise(r => setTimeout(r, backoff + Math.random() * 300));
}
}
}
Only retry idempotent requests by default. Replaying a POST that creates an order can produce a duplicate — if you must retry one, send an idempotency key so the server can deduplicate.
| Axios | fetch | |
|---|---|---|
| Bundle cost | ~13 KB gzipped | Zero — built in |
| JSON handling | Automatic both ways | JSON.stringify / await res.json() |
| Rejects on 4xx/5xx | Yes | No — check res.ok |
| Timeout | timeout option | AbortSignal.timeout() |
| Interceptors | Built in | Write a wrapper |
| Upload progress | Yes | No (download only, via streams) |
The equivalent timeout in plain fetch is now a one-liner:
const res = await fetch('/api/orders', { signal: AbortSignal.timeout(5000) });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
Reasonable rule: reach for fetch in new browser code, since it is built in and covers most needs. Choose Axios when you want interceptors, upload progress, or one client shared between browser and Node.
0 — no limit. Always set one.timeout bounds time-to-response, not connection or body streaming.AbortController; CancelToken is deprecated.err.code === 'ECONNABORTED'.err.response vs err.request to tell server errors from network failures.Advertisement
Written by
Amit VermaFounder / Senior Software Engineer
Amit leads engineering at Being Idea. With 15+ years building scalable software products across global markets, he drives architecture decisions and engineering culture across every engagement.
More articles by AmitYou might also like
We ship software that scales. Let's work together.