As of Node 18, fetch is built into Node — the same Promise-based API browsers have, now available server-side with no dependency to install. It's backed by undici, Node's modern HTTP client, and is the default choice for consuming APIs in new code. The API is small but has a few sharp edges that trip up newcomers — most famously that it does not throw on HTTP errors. This page covers GET and POST, headers and JSON, the all-important status check, timeouts with AbortController, streaming responses, and the gotchas that distinguish fetch from libraries like Axios.
A GET request
JS
// No import needed — fetch is a global in Node 18+.
const res = await fetch('https://api.github.com/users/nodejs', {
headers: { Accept: 'application/json' },
})
// res is a Response object. Check status BEFORE reading the body:
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const data = await res.json() // .json() returns a Promise — await it
console.log(data.name, data.public_repos)
Node.js 1200
`fetch` returns a `Response`; read the body with `await res.json()` (or `.text()`, `.arrayBuffer()`)
`fetch(url, options)` returns a Promise that resolves to a **`Response`** object — note this happens as soon as the *headers* arrive, *before* the body is downloaded. To get the body you call a method that returns *another* Promise: `res.json()` (parse as JSON), `res.text()` (raw string), `res.arrayBuffer()`/`res.blob()` (binary), or `res.body` (a stream). You can read the body only **once** — calling `.json()` then `.text()` on the same response throws. The `Response` also carries `res.status` (the numeric code), `res.ok` (true for `2xx`), `res.headers` (a `Headers` object), and `res.statusText`. This two-step shape — await the response, then await the body — is the rhythm of every fetch call.
The #1 gotcha — fetch does NOT throw on 404 or 500
JS
// ❌ WRONG — this "succeeds" even on a 500, then crashes parsing an error page:
const data = await fetch(url).then((r) => r.json())
// ✅ RIGHT — fetch only rejects on NETWORK failure, so check res.ok yourself:
const res = await fetch(url)
if (!res.ok) {
const body = await res.text() // capture the error detail
throw new Error(`Request failed ${res.status}: ${body}`)
}
const data = await res.json()
A `fetch` promise only rejects on network errors — a `404` or `500` resolves normally with `res.ok === false`
This is the single most common `fetch` mistake. Unlike Axios (which rejects on `4xx`/`5xx`), `fetch` **resolves successfully** for *any* HTTP response that came back — including `404`, `500`, and `503`. The promise only **rejects** when the request never completed at all: DNS failure, connection refused, TLS error, or a timeout/abort. So `await fetch(url)` followed straight by `.json()` will happily try to parse a `500` error page as JSON and throw a confusing syntax error far from the real cause. You **must** check `res.ok` (or `res.status`) yourself after every call and handle non-2xx responses explicitly. Wrapping fetch in a small helper that does this check once is the standard fix — and a big part of why many teams still prefer Axios's throw-by-default behavior.
A POST with a JSON body
JS
const res = await fetch('https://api.example.com/users', {
method: 'POST',
headers: {
'Content-Type': 'application/json', // you MUST set this for a JSON body
Authorization: `Bearer ${process.env.API_TOKEN}`,
},
body: JSON.stringify({ name: 'Ada', email: 'ada@x.com' }), // stringify it yourself
})
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const created = await res.json()
For JSON you set `Content-Type` and `JSON.stringify` the body yourself — fetch does neither automatically
To send data, set `method` and provide a `body`. Unlike Axios, `fetch` does **not** serialize objects or set the content type for you — for a JSON API you must do both manually: `JSON.stringify` the payload into `body` *and* set `'Content-Type': 'application/json'`, or the server won't know how to parse it. The `body` can also be a string, `URLSearchParams` (for form-urlencoded — content type is set automatically for this one), `FormData` (multipart, e.g. file uploads), or a stream/Buffer for binary. These small manual steps are the trade-off for `fetch` having zero dependencies; a thin wrapper function that takes an object and handles stringify + headers + the `res.ok` check removes the boilerplate.
Timeouts and cancellation with AbortController
JS
// Shortcut: AbortSignal.timeout() aborts automatically after N ms (Node 17.3+):
const res = await fetch(url, { signal: AbortSignal.timeout(5000) })
// Manual control — abort on demand (e.g. user cancels, or to race requests):
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), 5000)
try {
const res = await fetch(url, { signal: controller.signal })
return await res.json()
} catch (err) {
if (err.name === 'AbortError') throw new Error('Request timed out')
throw err
} finally {
clearTimeout(timer) // don't leak the timer
}
`fetch` has NO default timeout — without an `AbortSignal` a hung server keeps the request open forever
Out of the box `fetch` will wait **indefinitely** for a response — there is no built-in timeout option. A remote server that accepts your connection but never replies will hold the request open forever, and under load these hung requests pile up and exhaust your connection pool. You add a timeout via an **`AbortSignal`**: the one-liner `AbortSignal.timeout(ms)` aborts the request automatically, or use an `AbortController` directly for manual cancellation (let a user cancel, or abort losers when racing several requests). When a request is aborted, the fetch promise rejects with an `AbortError` — catch it to distinguish a timeout from other failures. Always set a timeout on every outbound call; "wait forever" is never the right default.
Streaming a large response
JS
// res.body is a stream — process a large/continuous response without buffering it all:
const res = await fetch('https://api.example.com/large-export')
if (!res.ok) throw new Error(`HTTP ${res.status}`)
for await (const chunk of res.body) { // async-iterate the body stream
process(chunk) // chunk is a Uint8Array
}
// In Node you can also pipe res.body straight to a file/Writable stream.
`res.body` is a readable stream — iterate it for large downloads or live token streams instead of buffering
Calling `res.json()` or `res.text()` buffers the *entire* response into memory — fine for normal API payloads, wasteful or impossible for huge downloads or never-ending streams. For those, read **`res.body`**, a readable stream you can `for await` over chunk by chunk (each a `Uint8Array`), processing or piping data as it arrives with constant memory. This is how you handle large file exports, CSV downloads, or **streaming AI completions** (reading [Server-Sent Events](/nodejs/server-sent-events) token by token from an LLM API). Combined with Node's [streams](/nodejs/streams-intro), you can pipe `res.body` directly to a file or transform it on the fly. Reach for streaming whenever the response is large or open-ended.
Use `fetch` for simple, dependency-free calls; reach for Axios when you want JSON, timeouts, and interceptors built in
`fetch` being built in and standards-based makes it the right default for simple cases and for code that must run in multiple runtimes (Node, browsers, edge/serverless). Its minimalism is also its cost: no automatic JSON, no default timeout, no error-on-bad-status, no interceptors — you build those conveniences yourself (usually as one shared wrapper). [**Axios**](/nodejs/axios) bundles all of that out of the box, which is why many teams still prefer it for apps that make lots of outbound calls needing shared base URLs, auth headers, retry logic, and request/response interceptors. Neither is wrong; pick `fetch` for lean and portable, Axios for batteries-included.
Next
The popular batteries-included alternative: [HTTP Requests with Axios](/nodejs/axios).