"In 2018 you needednode-fetch,axios, orgot. In 2026 you need none of them.fetchis in the global scope, stable, performant — powered byundiciunder 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
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
Cookieheaders manually or via a library. - Proxies via env vars —
HTTP_PROXY/HTTPS_PROXY/NO_PROXYwork via undici, but you may need to configure them explicitly throughsetGlobalDispatcher. - Streaming responses —
res.bodyis a Web ReadableStream, consumable withfor await. - HTTP/2 support — fetch in Node is HTTP/1.1 only as of Node 26. For HTTP/2, use
node:http2directly.
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
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.