Using the Native fetch() API
Modern Node.js ships a global fetch() function, so making HTTP requests no longer requires axios, node-fetch, or wiring up the low-level http module by hand. It implements the same WHATWG Fetch standard browsers use, which means the code you write on the server looks identical to front-end code. Under the hood it is powered by undici, Node’s high-performance HTTP/1.1 client. This page covers GET and POST requests, setting headers, sending and parsing JSON, cancelling requests with timeouts, and the one error-handling gotcha that trips up almost everyone.
Availability
fetch was added experimentally in Node 18 and became stable in Node 21. On any current LTS (20 or 22) it is available as a global — no import, no install. Because it is a global, it works the same in ES modules and CommonJS files alike.
// No import needed — fetch, Headers, Request, and Response are all globals.
console.log(typeof fetch); // 'function'
If you must support Node 18,
fetchexists but is gated behind the--experimental-fetchflag and emits a warning. On Node 20+ it is unflagged and stable, so target a current LTS whenever possible.
Making a GET request
fetch() returns a promise that resolves to a Response object as soon as the response headers arrive — the body has not been read yet at that point. You then call a body method such as .json(), .text(), or .arrayBuffer(), each of which returns its own promise. Reading the body is where the bytes are actually consumed off the wire.
async function getUser(id) {
const response = await fetch(`https://api.example.com/users/${id}`);
const user = await response.json();
return user;
}
const ada = await getUser(1);
console.log(ada.name);
Output:
Ada Lovelace
The Response object exposes everything about the reply: response.status, response.statusText, response.ok (a boolean that is true for any 2xx status), and a Headers object on response.headers.
const response = await fetch('https://api.example.com/health');
console.log(response.status, response.ok);
console.log(response.headers.get('content-type'));
Output:
200 true
application/json; charset=utf-8
Sending JSON with POST
To change the HTTP method, pass an options object as the second argument. For a JSON body you serialize the payload yourself with JSON.stringify() and set the Content-Type header — fetch does not do this automatically.
async function createUser(data) {
const response = await fetch('https://api.example.com/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
if (!response.ok) {
throw new Error(`Request failed: ${response.status}`);
}
return response.json();
}
const created = await createUser({ name: 'Grace Hopper', role: 'admin' });
console.log(created.id);
Output:
42
The body accepts more than strings: a URLSearchParams (sent as form-encoded), a Blob, an ArrayBuffer, a FormData instance, or even a ReadableStream for streaming uploads. For form submissions, URLSearchParams sets the correct Content-Type for you.
const response = await fetch('https://api.example.com/login', {
method: 'POST',
body: new URLSearchParams({ user: 'ada', token: 'secret' }),
});
Working with headers
You can supply request headers as a plain object or a Headers instance. A Headers object is convenient when you need to build headers conditionally or append multiple values, and it normalizes names case-insensitively.
const headers = new Headers();
headers.set('Authorization', 'Bearer ' + token);
headers.set('Accept', 'application/json');
const response = await fetch('https://api.example.com/me', { headers });
Timeouts with AbortSignal
fetch has no built-in timeout, so a slow server can leave a request hanging indefinitely. The standard solution is AbortSignal.timeout(), which returns a signal that aborts automatically after the given number of milliseconds. Pass it as the signal option. When the timeout fires, the promise rejects with a TimeoutError.
async function fetchWithTimeout(url, ms = 5000) {
try {
const response = await fetch(url, { signal: AbortSignal.timeout(ms) });
return await response.json();
} catch (err) {
if (err.name === 'TimeoutError') {
throw new Error(`Request to ${url} timed out after ${ms}ms`);
}
throw err;
}
}
For manual cancellation — say a user navigates away — use an AbortController directly and call controller.abort() when you want to stop the request.
const controller = new AbortController();
const promise = fetch('https://api.example.com/stream', { signal: controller.signal });
// Cancel after 2 seconds.
setTimeout(() => controller.abort(), 2000);
Error handling
This is the single most important thing to internalize: fetch does not throw on 4xx or 5xx responses. A 404 or 500 is still a completed HTTP transaction, so the promise resolves normally — response.ok will simply be false. The promise only rejects for network-level failures: DNS errors, refused connections, TLS problems, or an aborted request. You must inspect response.ok (or response.status) yourself.
async function safeGet(url) {
let response;
try {
response = await fetch(url);
} catch (err) {
// Network failure: DNS, connection refused, TLS, abort.
throw new Error(`Network error reaching ${url}: ${err.message}`);
}
if (!response.ok) {
const detail = await response.text();
throw new Error(`HTTP ${response.status} ${response.statusText}: ${detail}`);
}
return response.json();
}
The table below summarizes which conditions reject the promise versus which resolve with a non-ok response.
| Condition | Promise rejects? | response.ok |
|---|---|---|
| 2xx success | No | true |
| 4xx client error | No | false |
| 5xx server error | No | false |
| DNS / connection refused | Yes | n/a |
| TLS handshake failure | Yes | n/a |
| Aborted / timed out | Yes (AbortError/TimeoutError) | n/a |
A common bug is reading the body twice. A
Responsebody is a one-shot stream — once you call.json()(or.text()), calling another body method throws “Body is unusable”. If you need the raw text and parsed JSON, read.text()once andJSON.parse()it yourself.
Best Practices
- Always check
response.ok(or the status) before parsing —fetchnever throws on HTTP error codes. - Attach an
AbortSignal.timeout()to every outbound request so a hung server can’t stall your process. - Set
Content-Type: application/jsonandJSON.stringify()the body yourself for JSON POSTs;fetchwon’t infer it. - Read each response body exactly once; cache the result if you need it in multiple places.
- Prefer
URLSearchParamsfor form-encoded bodies andFormDatafor multipart uploads to get correct headers automatically. - Target Node 20+ LTS so
fetchis stable and unflagged; avoid relying on the experimental Node 18 implementation. - Catch the rejected promise separately from the
!response.okbranch so you can distinguish network failures from HTTP errors.