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:

  1. Calling fetch(url, options) returns a pending Promise immediately. Nothing blocks the event loop.
  2. 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.
  3. 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.
  4. Node writes the request line, headers, and body (if any) to the socket.
  5. 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 a Response object — the body has not been downloaded yet.
  6. Calling response.json(), response.text(), or reading response.body chunk by chunk drains the body stream. For .json()/.text(), Node buffers the whole body in memory before resolving; streaming via response.body processes it incrementally instead.
  7. 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 (or response.status) before trusting the body — fetch does 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/catch and distinguish network/abort errors from application-level errors you throw yourself.
  • Set an explicit Content-Type header when sending a body, and check the response’s content type before calling .json() on it.
  • Stream large downloads with response.body and pipeline() 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/users and prints just the name field of each user in the returned array.
  • Write a function fetchWithRetry(url, attempts) that retries a GET request up to attempts times 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 not ok.

Summary

  • fetch() is a global function in Node.js 18+ (stable since Node 21), backed by the undici HTTP 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()/.body is a separate async step.
  • fetch() only rejects for network-level failures — always check response.ok to 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(), and pipeline() instead of buffering the whole body in memory.
  • Unconsumed response bodies can hold connections open — drain or cancel a body you don’t need.