Axios is the most popular HTTP client for Node and the browser — a Promise-based library that wraps the raw request machinery with conveniences the built-in fetch leaves to you: automatic JSON serialization both ways, throwing on HTTP error statuses, a configurable timeout, reusable instances with a base URL and shared headers, and interceptors for cross-cutting logic like auth and logging. It's worth knowing well because so many codebases use it. This page covers requests and responses, creating configured instances, interceptors, error handling, and the trade-off versus plain fetch.
Basic requests
Bash
npm install axios
JS
import axios from 'axios'
// GET — the parsed body is on response.data (no manual .json() step):
const { data } = await axios.get('https://api.github.com/users/nodejs')
console.log(data.name)
// POST — pass a JS object; axios serializes it and sets Content-Type for you:
const res = await axios.post('https://api.example.com/users', {
name: 'Ada',
email: 'ada@x.com',
})
console.log(res.status, res.data)
// Query params via a config object (axios encodes them):
await axios.get('https://api.example.com/search', { params: { q: 'node', page: 2 } })
Axios parses JSON onto `response.data` and serializes request objects automatically — no stringify, no `.json()`
The everyday convenience of Axios is that JSON "just works" in both directions. On the way **out**, you pass a plain JavaScript object as the request body and Axios serializes it *and* sets `Content-Type: application/json` automatically — none of `fetch`'s manual `JSON.stringify` + header. On the way **in**, the parsed response body is already waiting on **`response.data`** — no separate `await res.json()` step. The response object also exposes `response.status`, `response.headers`, and `response.config`. Query parameters go in a `params` object that Axios URL-encodes for you. These small ergonomics, multiplied across a codebase full of API calls, are why Axios remains a default choice despite `fetch` now being built in.
Configured instances — a client per API
JS
// Create ONE instance per upstream API with shared config — DRY and centralized:
const github = axios.create({
baseURL: 'https://api.github.com', // prepended to every request path
timeout: 5000, // applies to every call
headers: { Authorization: `Bearer ${process.env.GITHUB_TOKEN}` },
})
// Now calls are short and consistent — just the path:
const { data: user } = await github.get('/users/nodejs')
const { data: repos } = await github.get('/users/nodejs/repos')
`axios.create()` makes a reusable client with a base URL, default headers, and a timeout — define one per upstream API
Rather than repeating the base URL, auth header, and timeout on every call, create a **configured instance** with `axios.create(config)`. The instance carries a `baseURL` (prepended to each request path), default `headers` (auth tokens, accept types), and a `timeout` — so individual calls shrink to just the path and any per-call overrides. The standard pattern is **one instance per upstream service** (a `github` client, a `stripe` client, an `internalApi` client), each centralizing that service's configuration in a single place. This keeps credentials and base URLs out of scattered call sites and makes it trivial to change them, add an interceptor, or swap a token — all in one module.
// REQUEST interceptor — runs before every request (inject auth, add a trace id):
github.interceptors.request.use((config) => {
config.headers['X-Request-Id'] = crypto.randomUUID()
return config // must return the (possibly modified) config
})
// RESPONSE interceptor — runs on every response (log, unwrap, refresh expired tokens):
github.interceptors.response.use(
(response) => response, // pass success through
async (error) => {
if (error.response?.status === 401) {
await refreshToken() // e.g. retry once after refresh
return github.request(error.config)
}
return Promise.reject(error) // re-throw everything else
},
)
Interceptors run code on every request or response — the place for auth injection, logging, and token refresh
**Interceptors** are Axios's standout feature: functions that run on *every* request or response passing through an instance, so cross-cutting concerns live in one place instead of every call site. A **request interceptor** can inject the latest auth token, attach a correlation/trace id, or log outgoing calls. A **response interceptor** has two handlers — one for success (unwrap data, log timing) and one for errors, which is the idiomatic home for **automatic token refresh** (catch a `401`, refresh the token, replay the original request) and for centralized retry/error normalization. `fetch` has no equivalent; you'd hand-build a wrapper to get the same behavior. Use interceptors for genuinely cross-cutting logic — keep per-call specifics in the calls themselves.
Error handling — Axios throws on bad status
JS
try {
const { data } = await axios.get('https://api.example.com/users/999')
return data
} catch (err) {
if (axios.isAxiosError(err)) {
if (err.response) {
// The server responded with a non-2xx status (4xx/5xx):
console.error('status:', err.response.status, 'body:', err.response.data)
} else if (err.request) {
console.error('no response — timeout or network error') // request sent, nothing back
} else {
console.error('config error:', err.message) // request never built
}
}
throw err
}
Axios rejects on `4xx`/`5xx` — handle it, and distinguish `err.response` (server replied) from `err.request` (no reply)
Opposite to [fetch](/nodejs/fetch-api), Axios **rejects the promise** on any non-2xx status by default — so a `404` or `500` lands in your `catch`, which many find more intuitive (you can't accidentally parse an error page as data). The cost is you must handle those rejections. Use `axios.isAxiosError(err)` to narrow the type, then branch on three cases: **`err.response`** exists → the server replied with an error status (inspect `err.response.status` and `.data`); **`err.request`** exists but no response → the request went out but nothing came back (timeout, network failure — a retry candidate); neither → the request couldn't even be built (a config/code bug). This three-way split tells you *where* the failure happened, which determines whether to retry, surface a user error, or fix your code. You can tune which statuses count as errors via `validateStatus` if you need different behavior.
fetch or Axios? — a practical take
Want…
Lean toward
Zero dependencies / portability across runtimes
fetch (built in)
Automatic JSON in & out
Axios (fetch needs manual handling)
Throw on 4xx/5xx by default
Axios
Shared base URL, headers, timeout per API
Axios instances
Interceptors (auth refresh, logging)
Axios
A handful of simple calls
fetch + a small wrapper
Upload progress / download progress events
Axios
Both are fine — `fetch` for lean/portable, Axios for batteries-included apps making many configured calls
There's no wrong answer, only a fit. Now that `fetch` is built in, a service making a *few* outbound calls — or one that must run in edge/serverless runtimes — is well served by `fetch` plus a thin shared wrapper that adds the `res.ok` check, JSON handling, and a timeout. An application making *many* calls to several APIs, wanting per-API instances, interceptor-based auth refresh, automatic JSON, and throw-on-error semantics out of the box, will write less code with **Axios**. Many teams even build their fetch wrapper to mimic Axios's conveniences — at which point the choice is mostly taste and dependency philosophy. Whichever you pick, the [consuming-APIs](/nodejs/consuming-apis) disciplines — timeouts, retries with backoff, status checks, secret management — apply equally.
Next
Now receive calls *from* other services instead of making them: [Building & Receiving Webhooks](/nodejs/webhooks).