node-fetch
node-fetch brought the browser's fetch() API to Node.js years before Node had one built in. It wraps Node's http/https modules in a window.fetch-compatible interface: the same Request, Response, and Headers classes, promise-based calls, and body helpers like res.json() and res.text(). It adds server-side conveniences the browser API lacks, such as headers.raw() for reading multiple Set-Cookie values, a redirect limit, a response size limit, and custom http.Agent support. Response bodies are native Node streams. The historical importance is huge; the practical question in 2026 is whether you need it at all, because Node 18 and later ship a native fetch built on undici.
A landmark package that native fetch has mostly retired. Keep it in older codebases and pre-18 Node targets; for new projects on modern Node, use the built-in fetch or undici and do not add this dependency.
Use it if
- You maintain an existing codebase already built on node-fetch and its FetchError handling, where ripping it out has no payoff
- You are stuck supporting Node versions older than 18 that have no native fetch, since v3 runs on Node 12.20+ and v2 goes back further
- You specifically need its extensions over spec fetch: headers.raw() for Set-Cookie arrays, per-request http.Agent injection for proxies and keep-alive tuning, or the highWaterMark stream knob
- You want response bodies as classic Node streams to pipe directly into fs or transform pipelines without touching web streams
- You are starting anything new on Node 18 or later: fetch is built into the runtime via undici, needs no install, and gets performance and security work as part of Node itself
- You use CommonJS and cannot migrate: v3 is ESM-only, so require('node-fetch') throws and you are pinned to the v2 line, which only receives critical fixes
- You want an actively developed HTTP client: the latest release (3.3.2) shipped in July 2023, a v4 has sat in beta since then, and the tracker holds around 252 open issues and PRs
- You need batteries like retries, hooks, pagination, or caching: got and axios ship those, node-fetch is deliberately minimal and undici gives you interceptors
Setup reality
npm install node-fetch pulls three small dependencies (fetch-blob, formdata-polyfill, data-uri-to-buffer). The real setup pain is module format: v3 is ESM-only, so CommonJS projects either pin node-fetch@2, use an awkward dynamic import() wrapper, or migrate their build. TypeScript users on v2 also need the separate @types/node-fetch package, while v3 bundles types. Then there are spec deviations to learn: 3xx-5xx responses do not reject the promise (check res.ok yourself), cookies are not stored, and there is no built-in timeout option since v3, only AbortSignal. If native fetch is also in scope, watch for subtle behavior differences around redirects and agents when switching between the two.
Patterns
GET a JSON endpointget-json
import fetch from 'node-fetch';
const response = await fetch('https://api.github.com/users/github');
const data = await response.json();
console.log(data.login);res.json() rejects on invalid JSON and the body can only be consumed once; check response.bodyUsed if you see 'body used already' errors.
POST a JSON bodypost-json
import fetch from 'node-fetch';
const response = await fetch('https://httpbin.org/post', {
method: 'POST',
body: JSON.stringify({ a: 1 }),
headers: { 'Content-Type': 'application/json' }
});
const data = await response.json();Unlike passing URLSearchParams or FormData, a string body sets Content-Type to text/plain unless you set the header yourself.
Use it from CommonJScommonjs-loading
// option 1: stay on the v2 line, which is CommonJS
// npm install node-fetch@2
const fetch = require('node-fetch');
// option 2: dynamic import of v3 from CJS
const fetch3 = (...args) =>
import('node-fetch').then(({ default: f }) => f(...args));require('node-fetch') on v3 throws ERR_REQUIRE_ESM. v2 (2.7.0) only gets critical fixes, and its TypeScript types come from @types/node-fetch.
Treat 4xx/5xx as failureshandle-http-errors
import fetch from 'node-fetch';
const response = await fetch('https://httpbin.org/status/500');
if (!response.ok) {
const body = await response.text();
throw new Error(`HTTP ${response.status}: ${body}`);
}Per the fetch spec, 3xx-5xx responses resolve normally; only network and operational failures reject, as FetchError. Forgetting the ok check is the classic bug.
Time out a request with AbortSignaltimeout-with-abort
import fetch, { AbortError } from 'node-fetch';
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 5000);
try {
const res = await fetch('https://slow.example.com', {
signal: controller.signal
});
console.log(res.status);
} catch (err) {
if (err instanceof AbortError) console.error('timed out');
} finally {
clearTimeout(timer);
}The v2-era timeout option is gone in v3; AbortController is the only mechanism. Aborting also destroys an in-flight body stream.
Stream a download to diskstream-to-file
import { createWriteStream } from 'node:fs';
import { pipeline } from 'node:stream/promises';
import fetch from 'node-fetch';
const response = await fetch('https://example.com/big.zip');
if (!response.ok) throw new Error(`unexpected ${response.status}`);
await pipeline(response.body, createWriteStream('./big.zip'));response.body is a classic Node readable stream, not a web stream like native fetch returns; pipeline handles error propagation and cleanup.
Keep-alive and per-protocol agentscustom-agent
import http from 'node:http';
import https from 'node:https';
import fetch from 'node-fetch';
const httpAgent = new http.Agent({ keepAlive: true });
const httpsAgent = new https.Agent({ keepAlive: true });
const res = await fetch('https://api.example.com', {
agent: (url) => (url.protocol === 'http:' ? httpAgent : httpsAgent)
});The agent option is a node-fetch extension that native fetch does not have; it is also the hook for proxy agents like https-proxy-agent.
Read every Set-Cookie headerread-set-cookie
import fetch from 'node-fetch';
const response = await fetch('https://example.com/login');
const cookies = response.headers.raw()['set-cookie'] ?? [];
for (const c of cookies) console.log(c.split(';')[0]);headers.get('set-cookie') joins multiple cookies into one comma-separated string, which breaks on cookies containing commas; raw() returns the real array.
Upload multipart form data with a fileupload-form-data
import fetch, { FormData, fileFromSync } from 'node-fetch';
const form = new FormData();
form.set('field', 'value');
form.set('avatar', fileFromSync('./avatar.png', 'image/png'));
const res = await fetch('https://httpbin.org/post', {
method: 'POST',
body: form
});v3 exports spec-compliant FormData plus fileFrom/fileFromSync helpers; the old form-data package from v2 days is no longer the recommended path.
Polyfill global fetch on old Nodeglobal-polyfill
// fetch-polyfill.js
import fetch, { Headers, Request, Response } from 'node-fetch';
if (!globalThis.fetch) {
globalThis.fetch = fetch;
globalThis.Headers = Headers;
globalThis.Request = Request;
globalThis.Response = Response;
}Only useful on Node 16 and earlier. On Node 18+ this guard leaves the native fetch in place, so the import becomes dead weight.
Control redirect behaviorlimit-redirects
import fetch from 'node-fetch';
const res = await fetch('https://httpbin.org/redirect/3', {
redirect: 'follow', // 'manual' | 'error' also supported
follow: 5 // node-fetch extension: max redirect count
});
console.log(res.redirected, res.url);follow: 0 with redirect: 'follow' errors on any redirect; redirect: 'manual' hands you the 3xx response with its location header untouched.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| undici | npm | You want the engine behind Node's native fetch directly, with interceptors, connection pooling, and the fastest request path. |
| got | npm | You want retries, hooks, pagination, and HTTP/2 handled for you in a Node-only ESM client. |
| axios | npm | You need one API across browser and Node with interceptors and wide team familiarity, and CommonJS support matters. |