Making HTTP Requests with fetch
Every real Node.js program eventually needs to talk to another server: calling a REST API, downloading a file, or pinging a webhook. Since Node.js 18 you don’t need a third-party library or the low-level http module just for this — the same fetch() function you already know from browser JavaScript is available globally in Node. It returns a Promise, works naturally with async/await, and is backed by Node’s own fast HTTP client, undici, instead of a wrapper around http.request. This lesson covers how fetch() behaves in Node, how to send GET and POST requests, how to add timeouts, how to stream large responses, and the mistakes that trip up almost everyone the first time.
Overview: How fetch Works in Node.js
fetch() has been available as a global — no require or import needed — since Node.js 18 (it printed an experimental warning at first, and has been fully stable and warning-free since Node.js 21). Under the hood it isn’t a rewrite of http.request; it’s implemented by undici, a separate HTTP/1.1 and HTTP/2 client written specifically for Node, exposed through the same Fetch Standard API used in browsers. That means Response, Headers, Request, and FormData are also global objects in Node.
Because it follows the browser Fetch spec, fetch() behaves a little differently from what server-side developers sometimes expect. It does not reject its promise for HTTP error statuses like 404 or 500 — only for network-level failures (DNS failure, connection refused, TLS error, or an aborted signal). It also does not manage cookies the way a browser does, and it has no built-in request timeout, so a request to an unresponsive server can sit pending far longer than your program should wait.
fetch() also resolves early: the returned promise settles as soon as the response headers arrive, not once the whole body has downloaded. Reading the body — via response.json(), response.text(), or the raw response.body stream — is a second, separate asynchronous step. This design is what lets you stream large responses to disk instead of buffering them entirely in memory.
Syntax
const response = await fetch(url, options);
| Part | Description |
|---|---|
resource |
The URL to request, as a string or a URL object. |
options.method |
HTTP method such as "GET", "POST", "PUT", "DELETE". Defaults to "GET". |
options.headers |
A plain object or a Headers instance with request headers. |
options.body |
The request body — a string, Buffer, FormData, or stream. Not allowed with GET/HEAD. |
options.signal |
An AbortSignal used to cancel the request or apply a timeout. |
| return value | A Promise that resolves to a Response once the response headers arrive. |
The most useful members of the resulting Response object:
| Member | Description |
|---|---|
response.ok |
true when the status is 200–299. fetch does not reject for 4xx/5xx. |
response.status |
The numeric HTTP status code. |
response.headers |
A Headers object; read with response.headers.get("content-type"). |
response.json() |
Reads and parses the body as JSON. Returns a Promise. |
response.text() |
Reads the body as a UTF-8 string. |
response.body |
The raw body as a web ReadableStream, for streaming large payloads. |
Examples
1. A simple GET request
The most common case: fetch a resource and parse it as JSON.
async function getTodo() {
const response = await fetch("https://jsonplaceholder.typicode.com/todos/1");
const todo = await response.json();
console.log(todo);
}
getTodo().catch((err) => console.error("Request failed:", err.message));
Output:
{ userId: 1, id: 1, title: 'delectus aut autem', completed: false }
fetch returns a promise, so await it to get the Response. Calling .json() reads the body stream, parses it, and returns another promise, so it needs its own await. Note the .catch() on the call to getTodo() — without it, a network failure would produce an unhandled promise rejection.
2. A POST request with a JSON body
To send data, set method, a Content-Type header, and a body. fetch never serializes objects for you — you must call JSON.stringify() yourself.
async function createPost() {
const response = await fetch("https://jsonplaceholder.typicode.com/posts", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title: "Hello Node", body: "Learning fetch", userId: 1 }),
});
if (!response.ok) {
throw new Error(`Request failed with status ${response.status}`);
}
const created = await response.json();
console.log("Created:", created);
}
createPost().catch((err) => console.error(err.message));
Output:
Created: { title: 'Hello Node', body: 'Learning fetch', userId: 1, id: 101 }
This example also checks response.ok before trusting the body — something the first example skipped for brevity but should not skip in real code (see Common Mistakes below).
3. A realistic example: timeout + streaming download to disk
For large responses, don’t buffer the whole thing with .json() or .text() — stream response.body straight to a file. This example also applies a 5-second timeout using AbortSignal.timeout(), available since Node 17.3.
const fs = require("node:fs");
const { pipeline } = require("node:stream/promises");
const { Readable } = require("node:stream");
async function downloadFile(url, destPath) {
const response = await fetch(url, { signal: AbortSignal.timeout(5000) });
if (!response.ok) {
throw new Error(`Download failed: ${response.status} ${response.statusText}`);
}
await pipeline(Readable.fromWeb(response.body), fs.createWriteStream(destPath));
console.log(`Saved to ${destPath}`);
}
downloadFile("https://jsonplaceholder.typicode.com/photos", "photos.json").catch((err) => {
console.error("Error:", err.message);
});
Output:
Saved to photos.json
response.body is a web-standard ReadableStream, not a Node stream, so Readable.fromWeb() converts it. Using pipeline() from node:stream/promises instead of manual .pipe() chaining matters here: pipeline() forwards errors from either side and closes the write stream automatically if the download fails partway through, which .pipe() does not do.
Under the Hood: What Happens During a fetch Call
It helps to know the actual sequence of events, since it explains both the early-resolution behavior and why streaming works the way it does:
- Calling
fetch(url, options)returns a pendingPromiseimmediately. Nothing blocks the event loop. - Node resolves the hostname to an IP address. For most hostnames this goes through the OS resolver, dispatched via libuv’s thread pool, unless the address is already cached.
- Undici opens a TCP socket to that IP (or reuses an already-open keep-alive socket from its connection pool for the same origin) and negotiates TLS if the URL is
https://. These are asynchronous socket operations handled by libuv’s event loop, not the thread pool. - Node writes the request line, headers, and body (if any) to the socket.
- The event loop’s poll phase watches the socket for incoming data. As soon as the response’s status line and headers arrive, the
fetch()promise resolves with aResponseobject — the body has not been downloaded yet. - Calling
response.json(),response.text(), or readingresponse.bodychunk by chunk drains the body stream. For.json()/.text(), Node buffers the whole body in memory before resolving; streaming viaresponse.bodyprocesses it incrementally instead. - Once the body is fully drained and the server keeps the connection alive, undici returns the socket to its pool so a later
fetch()to the same origin can reuse it instead of paying for a new TCP/TLS handshake.
Common Mistakes
Mistake 1: Assuming fetch throws on HTTP errors
fetch() only rejects for network failures. A 404 or 500 response is still a "successful" fetch as far as the promise is concerned.
async function getUser(id) {
const response = await fetch(`https://jsonplaceholder.typicode.com/users/${id}`);
const user = await response.json();
return user;
}
If id doesn’t exist, this silently returns whatever error body the API sent back (often {}) as if it were a real user. Always check response.ok first:
async function getUser(id) {
const response = await fetch(`https://jsonplaceholder.typicode.com/users/${id}`);
if (!response.ok) {
throw new Error(`User ${id} request failed: ${response.status}`);
}
return response.json();
}
Mistake 2: No timeout on the request
fetch() has no short default timeout. A slow or unresponsive server can leave the request pending far longer than your program can afford to wait.
const response = await fetch("https://slow-api.example.com/data");
const data = await response.json();
Pass an AbortSignal so the request gives up after a reasonable limit:
const response = await fetch("https://slow-api.example.com/data", {
signal: AbortSignal.timeout(5000),
});
const data = await response.json();
When the timeout fires, fetch() rejects with an AbortError, which a surrounding try/catch can handle distinctly from a real network error.
Mistake 3: Not draining a response body you don’t need
If you only care about the status code and never read the body, the underlying connection may not be released back to undici’s pool. Do this across many requests and you can exhaust available connections.
async function checkStatus(url) {
const response = await fetch(url);
return response.status;
}
Consume or explicitly cancel the body before returning:
async function checkStatus(url) {
const response = await fetch(url);
await response.body?.cancel();
return response.status;
}
Best Practices
- Always check
response.ok(orresponse.status) before trusting the body —fetchdoes not throw for HTTP error statuses. - Attach an
AbortSignal.timeout(ms)to every outbound request; never let a call wait indefinitely on a remote server. - Wrap requests in
try/catchand distinguish network/abort errors from application-level errors you throw yourself. - Set an explicit
Content-Typeheader when sending a body, and check the response’s content type before calling.json()on it. - Stream large downloads with
response.bodyandpipeline()instead of buffering them with.text()or.json(). - Avoid firing an unbounded number of concurrent
fetch()calls in a loop; batch them or limit concurrency to avoid overwhelming the remote server or your own connection pool. - Never log full response bodies or headers that might contain tokens, API keys, or personal data.
- Reuse the same origin across requests where possible so undici’s keep-alive pool can avoid repeated TCP/TLS handshakes.
Practice Exercises
- Write a script that fetches
https://jsonplaceholder.typicode.com/usersand prints just thenamefield of each user in the returned array. - Write a function
fetchWithRetry(url, attempts)that retries a GET request up toattemptstimes if the response status is 500 or higher, and throws after the final attempt fails. - Write a function that POSTs a JSON payload to an API, applies a 3-second timeout with
AbortSignal.timeout(), and throws a descriptive error that includes the status code when the response is notok.
Summary
fetch()is a global function in Node.js 18+ (stable since Node 21), backed by theundiciHTTP client — no import needed and no extra package required.- It returns a
Promise<Response>that resolves once headers arrive; reading the body with.json()/.text()/.bodyis a separate async step. fetch()only rejects for network-level failures — always checkresponse.okto catch HTTP error statuses.- There is no default timeout; use
signal: AbortSignal.timeout(ms)on every real request. - Stream large responses with
response.body,Readable.fromWeb(), andpipeline()instead of buffering the whole body in memory. - Unconsumed response bodies can hold connections open — drain or cancel a body you don’t need.
