Topic #28Core16 min read

Axios

The complete HTTP-client story: fetch vs Axios, configured instances, request/response interceptors, cancellation, retries, upload progress, and end-to-end type safety — beginner to advanced in one page.

#axios#fetch#http#interceptors#cancellation#retry#typescript#api

What is an HTTP client, and why not just use fetch? Every SPA needs to talk to a backend over HTTP. The browser ships a built-in client, fetch, and there is also a hugely popular library, Axios. fetch is standard and dependency-free, but it is deliberately low-level: it does not reject on 4xx/5xx (only on network failure), you must call res.json() yourself, there is no built-in timeout, no interceptors, and no automatic JSON stringifying. Axios wraps all of that: it parses JSON for you, rejects on non-2xx by default, supports timeouts, interceptors, and instances. Neither is 'correct' — know both and pick per project.

Configured instances. The first thing most apps do is create a configured instance with axios.create(...), setting a baseURL, a timeout, and default headers in one place. Every call made through that instance inherits the configuration, so you never repeat the base URL or content type. You can create several instances — one for your own API, one for a third-party service — each with its own config and interceptors, without polluting the global axios.

Request interceptors. A request interceptor runs before every outgoing call and can mutate the config. The canonical use is attaching the auth token from storage to the Authorization header, but it is also where you add a correlation/trace id, set a locale header, or log outgoing traffic. It must return the (possibly modified) config or a promise resolving to it.

Response interceptors. A response interceptor runs after every response — and, crucially, after every error. The canonical use is handling 401 Unauthorized globally by refreshing the token or redirecting to login, instead of writing that logic in every component. You register two functions: an onFulfilled for successful responses and an onRejected for errors; returning a rejected promise re-throws to the caller, while returning a value 'recovers' the call.

The error shape. With fetch you inspect res.ok and res.status manually. With Axios, a rejected error carries error.response (the server answered with a non-2xx — you have status, data, headers), error.request (the request was sent but no response came — network down, timeout, CORS), or neither (a config/setup error). The axios.isAxiosError(err) type guard narrows an unknown catch to this shape in TypeScript so you can branch on error.response?.status.

Cancellation. Long-lived or superseded requests (type-ahead search, a component that unmounts mid-flight) should be cancelled. Modern Axios and fetch both use the standard AbortController: create one, pass its signal into the request, and call controller.abort() to cancel. In React you typically abort in a useEffect cleanup so a stale response never overwrites fresh state. A cancelled request rejects with a distinguishable error (axios.isCancel(err) / an AbortError).

Timeouts. A request that hangs forever is worse than one that fails fast. Axios has a built-in timeout (milliseconds) per request or per instance; fetch has none, so you emulate it with an AbortController and a setTimeout that calls abort(). Always set a timeout on real network calls — the mobile-network hang is the classic bug.

Retries and backoff. Transient failures (5xx, 429, network blips) are worth retrying; deterministic ones (400, 422) are not. A retry helper loops up to N times on transient statuses, waiting an exponentially increasing delay (base * 2^attempt) with a little random jitter, and honors a Retry-After header on 429. Axios has a community plugin (axios-retry), but a ~15-line wrapper is often clearer and framework-agnostic.

Upload/download progress. Axios exposes onUploadProgress and onDownloadProgress callbacks giving you loaded/total bytes — perfect for a progress bar on file uploads. fetch requires reading the response body as a stream to get download progress and cannot report upload progress at all, which is one practical reason teams still reach for Axios.

Type safety with TypeScript. You parameterize the call (api.get<Expense[]>(...)) so the resolved response.data is fully typed. A thin wrapper like () => api.get<Expense[]>('/expenses').then(r => r.data) gives you typed functions your components and data-fetching hooks (React Query, SWR) can call directly. Keep these wrappers in one api/ module so the rest of the app never touches raw URLs or the client library.

Where Axios fits with data-fetching libraries. Axios is a transport; React Query / SWR are the cache and lifecycle layer. In practice Axios (or fetch) is the function you hand to useQuery, and the library handles caching, deduping, retries, and stale-while-revalidate. Don't reinvent caching inside interceptors — that is the query library's job.

Security note. Interceptors that read tokens from localStorage are convenient but expose the token to any XSS on the page. For sensitive apps prefer an httpOnly cookie set by the backend (the browser attaches it automatically, no interceptor needed) plus CSRF protection. The auth topic covers the trade-off in depth.

The mental model (memorise this). An HTTP client is your app's single door to the network: create an instance to configure that door once, use a request interceptor to stamp every outgoing letter (auth), a response interceptor to handle every reply centrally (401/refresh), an AbortController to tear up letters you no longer need, a timeout so you never wait forever, and generics so what comes back is typed all the way to the component.

Backend Analogy

An Axios instance with interceptors is the frontend equivalent of a Spring `RestTemplate`/`WebClient` (or a Vert.x `WebClient`) with registered `ClientHttpRequestInterceptor`s: you configure the base URL, timeouts, and default headers once and reuse it. The request interceptor that attaches a Bearer token is exactly like a server-side filter adding an auth header to every outbound call; the 401 response interceptor is your centralized exception handler. `AbortController` cancellation is the client analogue of a `CompletableFuture.cancel()` / Vert.x request timeout, and the exponential-backoff retry wrapper is the same pattern you'd build with Resilience4j `Retry`. Typing `api.get<T>` is choosing a typed `ParameterizedTypeReference<T>` so the deserialized body is a real type, not `Object`.

Key Insights
  • fetch is built-in and standard but low-level: it does not reject on 4xx/5xx, has no timeout, no interceptors, and needs a manual res.json() call. Axios adds all of those.
  • Create one configured instance (baseURL, timeout, headers) and import it everywhere — never scatter bare axios calls with full URLs across the app.
  • Request interceptors attach the auth token and trace ids; response interceptors handle cross-cutting errors like 401 globally so components stay clean.
  • The Axios error object has three shapes: error.response (server answered non-2xx), error.request (no response came), or a setup error. Narrow it with axios.isAxiosError.
  • Cancel superseded or unmounted requests with a standard AbortController and its signal; abort in a React useEffect cleanup to avoid stale-state overwrites.
  • Always set a timeout on real requests. Axios has one built in; fetch needs an AbortController plus setTimeout to emulate it.
  • Retry only transient failures (5xx, 429, network) with exponential backoff plus jitter and Retry-After; never retry 400/422.
  • Axios reports upload and download progress via onUploadProgress/onDownloadProgress; fetch cannot report upload progress at all.
  • Use generics (api.get<T>) and keep thin typed wrappers in one api module so response.data is typed end to end.
  • Reading tokens from localStorage in an interceptor is convenient but XSS-exposed; httpOnly cookies avoid the interceptor entirely at the cost of needing CSRF protection.

Worked Code

fetch vs Axios — the same GET, side by side
TypeScript
// --- fetch: low-level, does NOT throw on 404/500 ---
async function getUserFetch(id: number) {
  const res = await fetch(`/api/users/${id}`);
  // fetch only rejects on network failure, so we must check status ourselves
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
  return res.json(); // must parse the body manually
}

// --- Axios: parses JSON, rejects on non-2xx, typed ---
import axios from 'axios';

async function getUserAxios(id: number) {
  // response.data is already parsed; a 404/500 throws automatically
  const { data } = await axios.get<User>(`/api/users/${id}`);
  return data;
}
Instance, interceptors, and typed API calls
TypeScript
import axios from 'axios';

// Create ONE configured instance and import it everywhere
const api = axios.create({
  baseURL: import.meta.env.VITE_API_URL,
  timeout: 10000, // fail fast instead of hanging forever
  headers: { 'Content-Type': 'application/json' },
});

// Request interceptor — attach auth token + trace id to every call
api.interceptors.request.use(config => {
  const token = localStorage.getItem('token');
  if (token) config.headers.Authorization = `Bearer ${token}`;
  config.headers['X-Trace-Id'] = crypto.randomUUID();
  return config;
});

// Response interceptor — handle 401 globally, unwrap nothing
api.interceptors.response.use(
  response => response,
  error => {
    if (axios.isAxiosError(error) && error.response?.status === 401) {
      localStorage.removeItem('token');
      window.location.href = '/login';
    }
    return Promise.reject(error); // re-throw so callers can still catch
  },
);

// Type-safe wrappers — the rest of the app calls these, never raw URLs
export const getExpenses = () =>
  api.get<Expense[]>('/expenses').then(r => r.data);
export const createExpense = (body: Omit<Expense, 'id'>) =>
  api.post<Expense>('/expenses', body).then(r => r.data);
export const deleteExpense = (id: number) =>
  api.delete(`/expenses/${id}`);
Cancellation with AbortController (type-ahead search)
TypeScript
import axios from 'axios';

// Pass a signal; call abort() to cancel an in-flight request
function search(query: string, signal: AbortSignal) {
  return api.get<Result[]>('/search', { params: { q: query }, signal })
    .then(r => r.data);
}

// In React: abort the previous request when the query changes / unmounts
// useEffect(() => {
//   const controller = new AbortController();
//   search(query, controller.signal)
//     .then(setResults)
//     .catch(err => { if (!axios.isCancel(err)) showError(err); });
//   return () => controller.abort(); // cleanup cancels the stale call
// }, [query]);
Retry with exponential backoff (transient failures only)
TypeScript
import axios from 'axios';

const sleep = (ms: number) => new Promise(r => setTimeout(r, ms));

async function withRetry<T>(fn: () => Promise<T>, max = 3): Promise<T> {
  for (let attempt = 0; ; attempt++) {
    try {
      return await fn();
    } catch (err) {
      const status = axios.isAxiosError(err) ? err.response?.status : undefined;
      const transient = status === undefined || status >= 500 || status === 429;
      if (!transient || attempt >= max) throw err; // don't retry 400/422

      // exponential backoff with jitter: 300ms, 600ms, 1200ms (+/- randomness)
      const delay = 300 * 2 ** attempt + Math.random() * 100;
      await sleep(delay);
    }
  }
}

// usage: const data = await withRetry(() => getExpenses());
Upload with progress + a fetch timeout for comparison
TypeScript
// Axios reports upload progress natively
function uploadFile(file: File, onProgress: (pct: number) => void) {
  const form = new FormData();
  form.append('file', file);
  return api.post('/upload', form, {
    onUploadProgress: e => {
      if (e.total) onProgress(Math.round((e.loaded / e.total) * 100));
    },
  });
}

// fetch has no built-in timeout — emulate it with AbortController
async function fetchWithTimeout(url: string, ms = 8000) {
  const controller = new AbortController();
  const timer = setTimeout(() => controller.abort(), ms);
  try {
    return await fetch(url, { signal: controller.signal });
  } finally {
    clearTimeout(timer);
  }
}

Try It Live

Edit the code and press Run — it executes safely in a sandboxed iframe. Use the Console tab for log output.

Live fetch to a public API + status handling in the console

Interview-Ready Q&A

fetch is built into every browser, so it is zero-dependency and standard — good for small apps, edge/serverless, or when you want no bundle cost. But it is low-level: it only rejects on network failure (not on 4xx/5xx), has no timeout, no interceptors, no upload progress, and you must call res.json() yourself. Axios adds automatic JSON handling, rejects on non-2xx, timeouts, interceptors, instances, cancellation, and progress callbacks — worth the ~15KB in a larger app. Know both; the interceptor + instance ergonomics are the usual reason teams choose Axios.

Things to Remember
  • 1fetch: standard, no deps, low-level, does NOT throw on 4xx/5xx. Axios: interceptors, instances, timeout, cancellation, progress.
  • 2axios.create gives a reusable instance with shared baseURL/timeout/headers; register interceptors on it.
  • 3Request interceptor → attach token/trace id; response interceptor → handle 401/refresh globally.
  • 4Error shape: error.response (non-2xx), error.request (no response), or setup error — narrow with axios.isAxiosError.
  • 5Cancel with AbortController + signal; abort in useEffect cleanup to prevent stale-state writes.
  • 6Always set a timeout; retry only transient failures (5xx/429/network) with exponential backoff + jitter.
  • 7Generics (api.get<T>) make response.data typed; keep typed wrappers in one api/ module.
  • 8Prefer httpOnly cookies over localStorage tokens for sensitive apps to reduce XSS exposure.

References & Further Reading