Skip to main content
Being Idea Innovations
Axios Timeouts and Cancellation: What the Default Actually Does
Back to Blog

Axios Timeouts and Cancellation: What the Default Actually Does

3 May 20235 min readJavascript
Share:

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.

The basics

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.

Timeouts

// 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,
});

What timeout does not cover

This is the part worth knowing. Axios's timeout measures the time until a response arrives. It does not separately bound:

  • DNS resolution and TCP connection. In Node these are governed by the underlying agent, not by timeout.
  • Streaming response bodies. Once headers arrive the timeout is satisfied; a body that trickles in for ten minutes will not trip it.

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
  }
}

Detecting a timeout

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.

Cancellation

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();
}, []);

Instances and interceptors

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 with backoff

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 or fetch?

Axiosfetch
Bundle cost~13 KB gzippedZero — built in
JSON handlingAutomatic both waysJSON.stringify / await res.json()
Rejects on 4xx/5xxYesNo — check res.ok
Timeouttimeout optionAbortSignal.timeout()
InterceptorsBuilt inWrite a wrapper
Upload progressYesNo (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.

Summary

  • The default timeout is 0 — no limit. Always set one.
  • timeout bounds time-to-response, not connection or body streaming.
  • Use AbortController; CancelToken is deprecated.
  • A timeout surfaces as err.code === 'ECONNABORTED'.
  • Branch on err.response vs err.request to tell server errors from network failures.
  • Create an instance rather than mutating global defaults.
  • Guard interceptor retries against infinite loops.
  • Retry only transient failures, with backoff and jitter.
Share:

Written by

Amit Verma

Founder / 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 Amit

Want to talk tech?

We ship software that scales. Let's work together.

No long-term contracts
Senior engineers only
US · AU · NZ timezone coverage
14-day trial on retainers