C.W.K.
Stream
Lesson 03 of 05 · published

Native fetch — Node's Built-In HTTP Client

~12 min · io-net, fetch, undici, http-client

Level 0Node Curious
0 XP0/40 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete
"In 2018 you needed node-fetch, axios, or got. In 2026 you need none of them. fetch is in the global scope, stable, performant — powered by undici under the hood."

What Changed

Node 18 shipped fetch globally — same API as the browser. Node 21 marked it stable. By Node 22 it's the recommended HTTP client. The underlying implementation is undici, a high-performance HTTP/1.1 client written specifically for Node. fetch is a thin Web-spec-compatible wrapper around undici's lower-level Client/Pool/Agent APIs.

The killer fact: most third-party HTTP clients now exist for legacy compatibility, not capability. axios still ships because of its installed base, but a new project in 2026 has no reason to install it.

The Basics

// GET
const res = await fetch('https://api.github.com/zen');
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
const text = await res.text();
console.log(text);

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

One caveat people forget: fetch does not reject on HTTP error codes. A 404 or 500 returns a Response with res.ok === false. You must check res.ok yourself or read res.status. The promise only rejects on network failures (DNS, connection refused, timeout).

Differences From the Browser

Node's fetch follows the Web spec almost exactly, with a few practical differences:
  • No same-origin policy — Node fetch can call any origin without CORS. CORS is a browser security model; Node isn't a browser.
  • No cookies by default — Node doesn't ship a cookie jar. You manage Cookie headers manually or via a library.
  • Proxies via env varsHTTP_PROXY/HTTPS_PROXY/NO_PROXY work via undici, but you may need to configure them explicitly through setGlobalDispatcher.
  • Streaming responsesres.body is a Web ReadableStream, consumable with for await.
  • HTTP/2 support — fetch in Node is HTTP/1.1 only as of Node 26. For HTTP/2, use node:http2 directly.

Connection Reuse — The Hidden Performance Win

By default, undici pools connections per origin. Two consecutive fetch('https://api.example.com/...') calls reuse the same TCP+TLS connection, skipping the handshake on the second call. This is the #1 reason native fetch is faster than naive third-party clients that don't pool.

For high-throughput clients, you can tune the pool explicitly:

import { Agent, setGlobalDispatcher } from 'undici';

setGlobalDispatcher(new Agent({
  connections: 50,        // max parallel connections per origin
  keepAliveTimeout: 60_000,
  keepAliveMaxTimeout: 600_000,
}));

// All subsequent fetch() calls now use this agent

Cancellation and Timeouts

fetch accepts { signal } for AbortController cancellation. AbortSignal.timeout(ms) is the modern timeout idiom:

try {
  const res = await fetch('https://slow.example/data', {
    signal: AbortSignal.timeout(3_000),
  });
  // ...
} catch (e) {
  if (e.name === 'TimeoutError') console.log('took too long');
  else throw e;
}

Crucially, the abort also closes the underlying socket — no lingering connection. This is one place where native fetch differs from manual XHR-style implementations that just "forget" the response.

Pippa's Confession

For my first year I installed axios in every project. "It has a nicer API," I told Dad. "Compare them," he said. axios.get(url).then(r => r.data) vs fetch(url).then(r => r.json()) — the differences are surface-level. Then I checked the bundle size, the install graph, the maintainership status. axios brings a full transitive tree. Native fetch brings nothing — it's already in the runtime. I deleted axios from the next project and never missed it. The 2026 rule: native fetch unless you have a concrete reason otherwise, and "the docs example uses axios" isn't a concrete reason.

Code

Streaming JSONL from a fetch response·javascript
// Streaming a large response — don't buffer the whole body
const res = await fetch('https://api.example.com/huge.jsonl');
if (!res.ok) throw new Error(`${res.status}`);

const decoder = new TextDecoder();
let buf = '';
let count = 0;

for await (const chunk of res.body) {
  buf += decoder.decode(chunk, { stream: true });
  const lines = buf.split('\n');
  buf = lines.pop();
  for (const line of lines) {
    if (line) {
      const event = JSON.parse(line);
      count++;
      // process event
    }
  }
}
console.log(`processed ${count} events`);
Production-grade fetch wrapper with retries·javascript
// Retries with exponential backoff — a real production pattern
async function fetchWithRetry(url, init = {}, opts = {}) {
  const { retries = 3, baseMs = 200, signal } = opts;
  for (let attempt = 0; attempt <= retries; attempt++) {
    try {
      const r = await fetch(url, {
        ...init,
        signal: signal ?? AbortSignal.timeout(10_000),
      });
      if (r.ok) return r;
      if (r.status >= 400 && r.status < 500) return r;  // don't retry 4xx
      throw new Error(`HTTP ${r.status}`);
    } catch (e) {
      if (attempt === retries) throw e;
      const delay = baseMs * 2 ** attempt + Math.random() * 50;
      await new Promise(res => setTimeout(res, delay));
    }
  }
}

External links

Exercise

Build a paginated GitHub user-search CLI using only native fetch. Args: node search-users.mjs <query>. Walks pages until no more results, using Link header for the next-page URL (GitHub's pagination spec). Add AbortSignal.timeout(10_000) per page. Print each user's login and HTML URL. The lesson: fetch + a few headers is enough for real API consumption — no SDK required.
Hint
GitHub's response includes a Link: <https://api.github.com/...&page=2>; rel="next" header. Parse it: res.headers.get('link')?.match(/<([^>]+)>; rel="next"/)?.[1]. Use a User-Agent header (GitHub requires it). Rate limit headers in the response (x-ratelimit-remaining) tell you when to back off.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue
💛 by Ttoriwarm

Comments 0

🔔 Reply notifications (sign in)
Sign inPlease sign in to comment.

No comments yet — be the first.