| |

Node.js 22 🟢 Native Web Standard APIs (fetch, Headers, Request, Response)

For most of its history, Node.js had its own way of doing things. HTTP requests were made with the http and https modules, which were callback-based and verbose. Developers reached for libraries like request, then axios, then node-fetch to get something more ergonomic. The Fetch API was a browser thing, not a Node.js thing. That changed with Node.js 18, when fetch was added as an experimental global. By Node.js 21, it was stable. By Node.js 22, the entire family of Web Standard APIs—fetch, Headers, Request, Response, and FormData—are native, stable, and available without any import .

This chapter covers the Web Standard APIs that are now built into Node.js. You will learn how fetch works, how Headers, Request, and Response compose to represent HTTP messages, and how to handle different response types. You will also see how these APIs interact with Node.js streams and why this convergence matters for code that runs in both the browser and the server.

Key point: fetch in Node.js is the same fetch as in the browser. It is based on the WHATWG Fetch standard, implemented in Node.js using the undici library . The Request and Response objects are the same objects you get in the browser, and Headers behaves the same way. This means code written against the Fetch API works in both environments without modification.


Why native Web APIs exist

The fragmentation problem. Before Node.js 18, HTTP requests in Node.js required either the low-level http module or a third-party library. The http module is callback-based and does not support promises natively. axios and node-fetch filled the gap, but they were additional dependencies with their own APIs. A codebase targeting both browser and server had to maintain two HTTP layers, or use a library that abstracted over both. Native fetch eliminates this duplication .

The standard problem. The Fetch API is a WHATWG standard. It defines not just a function, but a family of objects—Request, Response, Headers, FormData—that model HTTP messages. These objects are used by service workers, fetch itself, and other web APIs. By implementing the standard, Node.js gains compatibility with the entire ecosystem of code written against it. Frameworks like Next.js, Remix, and SvelteKit rely on this convergence to share code between client and server .

The promise problem. The Fetch API is promise-based. fetch() returns a Promise<Response>. Reading the body returns a promise. This fits naturally into async/await code and error handling with try/catch. The callback-based http module required either manual promise wrapping or a library. Native fetch removes that friction .

The streaming problem. Response.body is a ReadableStream. This means large responses can be processed incrementally, without loading the entire body into memory. The Request body can be a stream too, enabling uploads without buffering. This streaming support is part of the standard and is available in Node.js through the Web Streams API that is also native .

The dependency problem. Every HTTP library is a dependency that must be updated, audited, and versioned. node-fetch, axios, and their transitive dependencies add to the dependency tree. Native fetch has zero dependencies. It is part of the runtime. For projects that want to minimize dependencies, this matters .


a. The fetch() function and basic requests

fetch() is a global function. It takes a URL and an optional options object, and returns a promise that resolves to a Response.

const response = await fetch('https://api.github.com/users/octocat');
console.log(response.status); // 200
console.log(response.ok);     // true

The response.ok property is true when the status code is in the 200–299 range. It does not reject on HTTP errors—a 404 or 500 response resolves normally with ok: false. This is different from libraries that throw on non-2xx responses. The caller must check response.ok or response.status explicitly .

const response = await fetch('https://api.github.com/users/nonexistent');
if (!response.ok) {
  throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}

The options object configures the request. method sets the HTTP verb. headers sets request headers. body sets the request body. signal attaches an AbortSignal for cancellation.

const response = await fetch('https://api.example.com/users', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'Authorization': `Bearer ${token}`
  },
  body: JSON.stringify({ name: 'Alice' })
});

The Headers object can be passed directly instead of a plain object, which is useful when you need to manipulate headers programmatically.


b. Headers, Request, and Response

The Headers class represents a collection of HTTP headers. It is a map-like object with methods that handle case-insensitivity and multiple values correctly.

const headers = new Headers();
headers.append('Content-Type', 'application/json');
headers.append('X-Custom', 'value');

console.log(headers.get('content-type')); // 'application/json'
console.log(headers.has('X-Custom'));     // true

Header names are case-insensitive. headers.get('Content-Type') and headers.get('content-type') return the same value. The append method adds a value to an existing header rather than replacing it, which is important for headers that can appear multiple times .

A Headers object has a “guard” that restricts what can be modified. A Request‘s headers have a guard of request, which allows modifying most headers but forbids forbidden request headers like Host. A Response‘s headers have a guard of response, which allows modifying most headers but forbids forbidden response headers like Content-Length and Set-Cookie .

The Request class represents an HTTP request. It is not commonly constructed directly—fetch creates one internally—but it can be useful for pre-configuring requests.

const request = new Request('https://api.example.com/data', {
  method: 'GET',
  headers: { 'Accept': 'application/json' }
});

const response = await fetch(request);

A Request can be cloned before being sent, which is useful when you need to send the same request multiple times or inspect it before sending.

The Response class represents the server’s reply. It has properties for status, status text, headers, URL, and body.

const response = await fetch('https://api.example.com/data');

console.log(response.status);      // 200
console.log(response.statusText);  // 'OK'
console.log(response.url);         // final URL after redirects
console.log(response.redirected);  // true if redirected
console.log(response.type);        // 'basic', 'cors', 'error', 'opaque'

The Response body is a ReadableStream. It can be consumed only once. Calling response.json() or response.text() consumes the body and returns a promise. Calling response.clone() before consuming creates a copy whose body can be consumed independently.

const response = await fetch('https://api.example.com/data');
const clone = response.clone();

const json = await response.json();
const text = await clone.text();

The Response constructor can create a response programmatically, which is useful in service workers or when mocking HTTP responses in tests.

const response = new Response(JSON.stringify({ ok: true }), {
  status: 200,
  headers: { 'Content-Type': 'application/json' }
});

c. Response bodies and error handling

A Response body can be read in several formats. Each method returns a promise and consumes the body.

const json = await response.json();           // parse as JSON
const text = await response.text();           // as string
const buffer = await response.arrayBuffer();  // as ArrayBuffer
const blob = await response.blob();           // as Blob (browser)
const stream = response.body;                 // ReadableStream

The streaming interface is what makes fetch different from buffered libraries. For large responses—file downloads, streaming APIs—reading the body as a stream avoids loading everything into memory.

const response = await fetch('https://example.com/large-file');
const reader = response.body.getReader();

while (true) {
  const { value, done } = await reader.read();
  if (done) break;
  processChunk(value);
}

Error handling in fetch has two distinct layers. A network failure—DNS resolution failure, connection refused, TLS error—rejects the promise. An HTTP error response—404, 500—resolves normally with response.ok === false. The caller must check response.ok to distinguish success from failure at the HTTP level .

try {
  const response = await fetch('https://api.example.com/data');
  if (!response.ok) {
    throw new Error(`HTTP ${response.status}`);
  }
  const data = await response.json();
} catch (error) {
  // Network error or thrown HTTP error
  console.error('Request failed:', error);
}

The AbortSignal allows cancelling a request. This is useful for timeouts, user-initiated cancellation, or cleaning up pending requests when a component unmounts.

const controller = new AbortController();
setTimeout(() => controller.abort(), 5000);

try {
  const response = await fetch(url, { signal: controller.signal });
} catch (error) {
  if (error.name === 'AbortError') {
    console.log('Request timed out');
  }
}

Complete Example Session

// ============================================
// PART 1: BASIC GET REQUEST
// ============================================
const response = await fetch('https://api.github.com/users/octocat');
const data = await response.json();
console.log(data.login); // 'octocat'
// ============================================
// PART 2: CHECKING RESPONSE STATUS
// ============================================
const response = await fetch('https://api.github.com/users/nonexistent');
if (!response.ok) {
  console.log(`Error: ${response.status} ${response.statusText}`);
}
// ============================================
// PART 3: POST REQUEST WITH HEADERS
// ============================================
const response = await fetch('https://httpbin.org/post', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'X-Custom-Header': 'value'
  },
  body: JSON.stringify({ name: 'Alice', age: 30 })
});
const result = await response.json();
console.log(result.json);
// ============================================
// PART 4: READING RESPONSE HEADERS
// ============================================
const response = await fetch('https://api.github.com/users/octocat');
response.headers.forEach((value, key) => {
  console.log(`${key}: ${value}`);
});
console.log(response.headers.get('content-type'));
// ============================================
// PART 5: CREATING HEADERS OBJECT
// ============================================
const headers = new Headers();
headers.append('Content-Type', 'application/json');
headers.append('Accept', 'application/json');
headers.set('Authorization', 'Bearer token123');

console.log(headers.get('content-type')); // 'application/json'
console.log(headers.has('accept'));       // true
// ============================================
// PART 6: REQUEST OBJECT
// ============================================
const request = new Request('https://api.github.com/users/octocat', {
  method: 'GET',
  headers: { 'Accept': 'application/vnd.github+json' }
});
const response = await fetch(request);
// ============================================
// PART 7: CLONING RESPONSE
// ============================================
const response = await fetch('https://api.github.com/users/octocat');
const clone = response.clone();

const json = await response.json();
const text = await clone.text();

console.log(json.login);
console.log(text.length);
// ============================================
// PART 8: STREAMING RESPONSE BODY
// ============================================
const response = await fetch('https://example.com/large-data');
const reader = response.body.getReader();
let total = 0;

while (true) {
  const { value, done } = await reader.read();
  if (done) break;
  total += value.length;
}
console.log(`Received ${total} bytes`);
// ============================================
// PART 9: ABORT SIGNAL
// ============================================
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 3000);

try {
  const response = await fetch('https://example.com/slow', {
    signal: controller.signal
  });
  clearTimeout(timeout);
  console.log(await response.text());
} catch (error) {
  if (error.name === 'AbortError') {
    console.log('Request aborted');
  }
}
// ============================================
// PART 10: CREATING RESPONSE
// ============================================
const response = new Response(JSON.stringify({ status: 'ok' }), {
  status: 200,
  statusText: 'OK',
  headers: { 'Content-Type': 'application/json' }
});

console.log(response.status); // 200
console.log(await response.json()); // { status: 'ok' }

The ten parts covered basic GET requests, status checking, POST with headers, reading response headers, creating Headers, using Request, cloning responses, streaming bodies, abort signals, and creating Response objects.


Quick Reference

Fetch API Classes

ClassPurposeGlobal
fetch()Make HTTP requestYes
HeadersHTTP headers collectionYes
RequestHTTP request representationYes
ResponseHTTP response representationYes
FormDataMultipart form dataYes

Fetch Options

OptionTypeDefaultPurpose
methodstring'GET'HTTP method
headersHeaders/object{}Request headers
bodystring/streamnullRequest body
signalAbortSignalnullCancellation
redirectstring'follow'Redirect handling
credentialsstring'same-origin'Cookie handling

Response Body Methods

MethodReturnsConsumes Body
json()Promise<object>Yes
text()Promise<string>Yes
arrayBuffer()Promise<ArrayBuffer>Yes
blob()Promise<Blob>Yes
bodyReadableStreamNo

Response Properties

PropertyTypeDescription
okbooleanStatus in 200–299
statusnumberHTTP status code
statusTextstringStatus message
headersHeadersResponse headers
urlstringFinal URL
redirectedbooleanWas redirected

Best Practices

✅ Do This:

// Always check response.ok for HTTP errors
const response = await fetch(url);
if (!response.ok) throw new Error(response.statusText);      // ✅

// Use async/await for readability
const data = await response.json();                           // ✅

// Set Content-Type for POST bodies
headers: { 'Content-Type': 'application/json' }               // ✅

// Use AbortSignal for timeouts
const controller = new AbortController();                    // ✅

// Clone before consuming if you need the body twice
const clone = response.clone();                               // ✅

// Stream large responses
const reader = response.body.getReader();                     // ✅

// Reuse Headers objects
const headers = new Headers({ 'Accept': 'application/json' }); // ✅

❌ Don’t Do This:

// Don't assume fetch rejects on 404
const response = await fetch(url);
const data = await response.json(); // may fail if body is empty    // ❌

// Don't consume body twice
await response.json();
await response.text(); // Error: body already consumed               // ❌

// Don't forget await on body methods
const data = response.json(); // Promise, not data                  // ❌

// Don't use fetch for file:// URLs
await fetch('file:///etc/passwd'); // Not supported in Node.js      // ❌

// Don't ignore the signal option for long requests
await fetch(url); // No timeout, hangs indefinitely                  // ⚠️

Common Pitfalls

PitfallWhy It HappensFix
fetch resolves on 404HTTP errors are not network errorsCheck response.ok
Body already consumedBody is a stream, read onceClone before reading
response.json() failsBody is not JSONCheck Content-Type
Request hangsNo timeoutUse AbortSignal
Headers not setPlain object lacks methodsUse Headers or plain object
CORS error in browserServer lacks CORS headersServer must allow origin
Redirect not followedredirect: 'manual'Use default 'follow'

Real-World Examples

1. GitHub API

const response = await fetch('https://api.github.com/repos/nodejs/node');
const repo = await response.json();
console.log(repo.stargazers_count);

2. POST JSON

await fetch('https://api.example.com/users', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ name: 'Alice' })
});

3. Bearer Token Auth

const response = await fetch(url, {
  headers: { 'Authorization': `Bearer ${token}` }
});

4. File Download

const response = await fetch(fileUrl);
const buffer = await response.arrayBuffer();
fs.writeFileSync('file.bin', Buffer.from(buffer));

5. Streaming Text

const response = await fetch(streamUrl);
for await (const chunk of response.body) {
  process(chunk);
}

6. Timeout Request

const controller = new AbortController();
setTimeout(() => controller.abort(), 5000);
await fetch(url, { signal: controller.signal });

7. Headers Iteration

response.headers.forEach((value, name) => {
  console.log(`${name}: ${value}`);
});

8. Check Content Type

const contentType = response.headers.get('content-type');
if (contentType?.includes('application/json')) {
  return response.json();
}
return response.text();

9. Error Response

if (!response.ok) {
  const error = await response.json();
  throw new Error(error.message);
}

10. FormData Upload

const form = new FormData();
form.append('file', fileBuffer, 'file.txt');
await fetch(url, { method: 'POST', body: form });

Visual

Fetch Request/Response Flow

┌─────────────────────────────────────────────────────────────┐
│  FETCH REQUEST/RESPONSE FLOW                                │
│                                                             │
│  fetch(url, options)                                        │
│    │                                                        │
│    ▼                                                        │
│  ┌─────────────────────────────────────────────────────┐    │
│  │  Request object created                             │    │
│  │  ├── method: GET                                    │    │
│  │  ├── headers: Headers                               │    │
│  │  └── body: null                                     │    │
│  └─────────────────────────────────────────────────────┘    │
│    │                                                        │
│    ▼                                                        │
│  Network request sent                                       │
│    │                                                        │
│    ▼                                                        │
│  ┌─────────────────────────────────────────────────────┐    │
│  │  Response object resolved                           │    │
│  │  ├── status: 200                                    │    │
│  │  ├── ok: true                                       │    │
│  │  ├── headers: Headers                               │    │
│  │  └── body: ReadableStream                           │    │
│  └─────────────────────────────────────────────────────┘    │
│    │                                                        │
│    ▼                                                        │
│  await response.json()                                      │
│    │                                                        │
│    ▼                                                        │
│  Body consumed, parsed data returned                        │
│                                                             │
└─────────────────────────────────────────────────────────────┘

Response Body Consumption

┌─────────────────────────────────────────────────────────────┐
│  RESPONSE BODY IS A STREAM                                  │
│                                                             │
│  response.body ──▶ ReadableStream                           │
│       │                                                     │
│       ├── .json()      ──▶ parse as JSON                    │
│       ├── .text()      ──▶ string                           │
│       ├── .arrayBuffer() ──▶ binary                         │
│       ├── .blob()      ──▶ Blob (browser)                   │
│       └── .getReader() ──▶ incremental chunks               │
│                                                             │
│  Body can be consumed ONCE.                                 │
│  Use response.clone() for multiple reads.                   │
│                                                             │
└─────────────────────────────────────────────────────────────┘

Error Handling Layers

┌─────────────────────────────────────────────────────────────┐
│  NETWORK ERROR (Rejects)                                    │
│                                                             │
│  ├── DNS resolution failure                                 │
│  ├── Connection refused                                     │
│  ├── TLS handshake failure                                  │
│  └── Timeout (AbortError)                                   │
│                                                             │
│  try/catch catches these.                                   │
│                                                             │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  HTTP ERROR (Resolves with ok: false)                       │
│                                                             │
│  ├── 404 Not Found                                          │
│  ├── 500 Internal Server Error                              │
│  └── 401 Unauthorized                                       │
│                                                             │
│  Must check response.ok or response.status.                 │
│  fetch does NOT reject on these.                            │
│                                                             │
└─────────────────────────────────────────────────────────────┘

Node.js Version History

┌─────────────────────────────────────────────────────────────┐
│  FETCH IN NODE.JS                                           │
│                                                             │
│  Node.js 18 (April 2022)                                    │
│  ├── fetch added as experimental global                     │
│  └── Requires --experimental-fetch flag                     │
│                                                             │
│  Node.js 21 (October 2023)                                  │
│  └── fetch marked STABLE                                    │
│                                                             │
│  Node.js 22 (April 2024)                                    │
│  ├── fetch stable and enabled by default                    │
│  ├── WebSocket enabled by default                           │
│  └── Full Web Platform convergence                          │
│                                                             │
└─────────────────────────────────────────────────────────────┘

Summary

ItemValue
fetch()Global function, returns Promise<Response>
HeadersCase-insensitive header collection
RequestHTTP request representation
ResponseHTTP response representation
StabilityStable since Node.js 21
DefaultEnabled by default in Node.js 22
ImplementationBased on undici
Body methodsjson(), text(), arrayBuffer(), blob()
Streamingresponse.body is ReadableStream
Error handlingNetwork errors reject; HTTP errors resolve
AbortAbortController + signal option

Key takeaways:

  • fetch is native and stable. Node.js 21 marked it stable, and Node.js 22 enables it by default. No import, no node-fetch dependency. The same fetch works in the browser and in Node.js .
  • fetch does not reject on HTTP errors. A 404 or 500 resolves normally with response.ok === false. The caller must check response.ok or response.status to detect HTTP-level failures .
  • The body is a stream and can be read once. response.json() consumes the body. If you need to read it multiple times, call response.clone() before the first read.
  • Headers is case-insensitive. headers.get('Content-Type') and headers.get('content-type') return the same value. The append method adds rather than replaces, which matters for multi-value headers .
  • AbortSignal enables cancellation. Pass signal to fetch and call controller.abort() to cancel. The promise rejects with an AbortError, which can be distinguished from other errors by checking error.name.
  • Streaming responses avoid buffering. response.body.getReader() returns chunks incrementally. This is useful for large downloads or streaming APIs where loading the entire response into memory is impractical .
  • The Web Standard APIs are converging. fetch, Headers, Request, Response, and FormData are the same across browsers and Node.js. Code written against them runs in both environments, which simplifies isomorphic applications and edge deployments .
  • Node.js 22 completes the convergence. With fetch stable, WebSocket enabled by default, and FormData native, Node.js now implements the core Web Platform APIs natively. This is the end of the browser-versus-server JavaScript divide .

Remember: The Fetch API is not just a convenience over http.request. It is a standard that unifies HTTP handling across JavaScript environments. In Node.js, it is implemented with undici, stable since version 21, and enabled by default in version 22. Use it for new code. Use it for shared code between client and server. Use it when you want promise-based HTTP with streaming bodies and abort support. The Headers, Request, and Response objects are the same objects the browser provides, which means the HTTP layer of your application no longer needs to be environment-specific.



Stop using slow, ad-bloated tool sites! 🤮

🔎 Search “KandZ Tools” on Google to use many professional utilities for free.

KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)

⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free

🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!