cheat sheet
node-fetch
Package-level reference for node-fetch on npm — v2 (CJS) vs v3 (ESM), why Node 18+ built-in fetch obviates it, and migration paths.
node-fetch
What it is
node-fetch is the Node.js polyfill for the WHATWG fetch() API. From 2016 to 2022 it was the de-facto way to make HTTP requests on the server without using the verbose http/https modules or pulling in a larger client like axios or got.
The honest framing in 2026: you almost certainly don't need this anymore. Node ≥18 ships a built-in global fetch (backed by undici) that's compatible with the same API. node-fetch is now mostly relevant for:
- CJS codebases that can't migrate to ESM and need v2 (
require("node-fetch")works on v2 only). - Node 16 and earlier legacy maintenance.
- Specific feature parity that the built-in lacks — though by 2026, the built-in covers virtually everything.
If you're starting a new project on Node 18+, use the built-in fetch and skip this package.
Install
# npm / pnpm / yarn / bun — v2 (CJS-compatible)
npm install node-fetch@2
# v3 (ESM-only)
npm install node-fetch
Output: ~80 KB unpacked. Runtime dep — your code calls fetch at runtime.
# TypeScript declarations
npm install --save-dev @types/node-fetch@2 # for v2
# v3 bundles its own types
Output: types bundled in v3+. For v2, install DefinitelyTyped declarations.
# DON'T install at all on Node 18+ — fetch is global
const res = await fetch("https://api.example.com"); // just works
Output: if you're on Node 18+, this is the cleanest option. No install needed.
Versioning & Node support
v2.x— CJS, runs on Node 4+. Long-lived legacy line. Still receives security backports. Use this for any CJS codebase.v3.x— ESM-only, requires Node 12.20+. The ESM transition brokerequire("node-fetch")and made it incompatible with most existing CJS code. Use only if your codebase is ESM-native.- There's no
v4planned. The maintainers have statednode-fetchis in maintenance mode now that Node ships built-in fetch. - Always a runtime dependency if used.
Package metadata
- Maintainer: David Frank (
@bitinn) + node-fetch org - Project home: github.com/node-fetch/node-fetch
- Docs: github.com/node-fetch/node-fetch#readme
- npm: npmjs.com/package/node-fetch
- License: MIT
- First released: 2016
- Downloads: ~80 million per week — declining since Node 18 made
fetchglobal
Peer dependencies & extras
| Package | Purpose |
|---|---|
@types/node-fetch | TS declarations for v2 (v3 bundles types). |
form-data | Multipart bodies — pair with node-fetch for file uploads. |
https-proxy-agent | HTTPS proxy support — pass via agent: option. |
tough-cookie | Cookie jar — node-fetch doesn't store cookies; pair manually. |
abort-controller | Polyfill for Node <14.17 (Node 15+ has it built in). |
Alternatives
| Library | Trade-off |
|---|---|
Built-in fetch (Node 18+) | Zero-dep, modern, undici-backed, fast. Same API. Pick this first. |
| undici | The library behind Node's built-in fetch. Also exports lower-level Client/Pool/Dispatcher. Pick for max performance or HTTP/2. |
| axios | Interceptors, transformers, automatic JSON serialization, cancellation tokens, broader API. Pick if you need a feature-rich HTTP client. |
| got | Streams, retries, pagination, cookie jars built in. Pick for production-grade HTTP with retries. |
| ky | Modern minimal fetch wrapper (~3 KB) with retries and timeout. Works on Node + browsers + Workers. Pick for cross-runtime apps. |
| isomorphic-fetch | Older universal wrapper. Mostly redundant now. |
| cross-fetch | Used to be the cross-platform glue. Mostly redundant on Node 18+. |
Common gotchas
node-fetchv3 is ESM-only.require("node-fetch")throws on v3. Either upgrade your codebase to ESM, use dynamicimport(), or pin v2.- Built-in
fetchandnode-fetchare not identical.Response.bodyis a NodeReadablein node-fetch v2; in node-fetch v3 and built-in fetch, it's aReadableStream(web standard). Stream consumption code differs. - No automatic cookie jar. Unlike axios/got/
fetchin a browser,node-fetchdoesn't store cookies between requests. Pair withtough-cookieif needed. AbortControllerpolyfill needed for Node <14.17. Node 15+ has it global; older Nodes neednpm install abort-controller.- Timeouts aren't first-class. Use
AbortController+setTimeout. Don't rely ontimeoutoption in v3 (removed). agent:is Node-only. The built-in fetch uses undici's dispatcher; node-fetch uses Node'shttp.Agent. Migration between the two means rewriting any agent-based code.
Real-world recipes
Basic GET
// node-fetch v3 (ESM)
import fetch from "node-fetch";
const res = await fetch("https://api.example.com/users/1");
const user = await res.json();
console.log(user);
Output:
{ "id": 1, "name": "Alice", "email": "alice@example.com" }
On Node 18+ — just drop the import:
const res = await fetch("https://api.example.com/users/1");
const user = await res.json();
Same code, zero dep.
POST JSON
const res = await fetch("https://api.example.com/users", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "Alice", email: "alice@example.com" }),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const created = await res.json();
Output:
{ "id": 42, "name": "Alice", "email": "alice@example.com", "createdAt": "2026-05-31T..." }
fetch doesn't throw on 4xx/5xx — only on network errors. Always check res.ok.
Stream response (large file download)
import fetch from "node-fetch";
import fs from "node:fs";
import { pipeline } from "node:stream/promises";
const res = await fetch("https://example.com/large-file.zip");
if (!res.ok) throw new Error(`HTTP ${res.status}`);
await pipeline(res.body as any, fs.createWriteStream("./large-file.zip"));
console.log("downloaded");
Output:
downloaded
res.body is a Node Readable in v2 (pipe directly); in v3 and built-in fetch it's a ReadableStream — convert with Readable.fromWeb(res.body):
import { Readable } from "node:stream";
await pipeline(Readable.fromWeb(res.body as any), fs.createWriteStream("./out.zip"));
AbortController — timeouts and cancellation
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
try {
const res = await fetch("https://slow.example.com", {
signal: controller.signal,
});
console.log(await res.text());
} catch (err) {
if ((err as any).name === "AbortError") {
console.error("request timed out");
} else {
throw err;
}
} finally {
clearTimeout(timeout);
}
Output: timeout after 5s. AbortController is the canonical pattern for both node-fetch and built-in fetch — no library-specific timeout option works as well.
For brevity in modern Node (≥17.3), use AbortSignal.timeout(5000):
const res = await fetch(url, { signal: AbortSignal.timeout(5000) });
Concurrent requests with Promise.all
const urls = ["a.json", "b.json", "c.json"].map((p) => `https://example.com/${p}`);
const results = await Promise.all(
urls.map(async (url) => {
const res = await fetch(url);
if (!res.ok) throw new Error(`${url}: HTTP ${res.status}`);
return res.json();
})
);
console.log(results.length, "files fetched");
Output:
3 files fetched
For very large parallel batches, throttle with p-limit to avoid exhausting connections.
Custom HTTP agent (connection pooling, keep-alive)
import fetch from "node-fetch";
import { HttpsAgent } from "agentkeepalive";
const agent = new HttpsAgent({
maxSockets: 100,
maxFreeSockets: 10,
timeout: 60000,
freeSocketTimeout: 30000,
});
const res = await fetch("https://api.example.com", { agent });
Output: persistent connection pool — significant speedup for repeated requests to the same host. The built-in fetch uses undici's dispatcher; configure via setGlobalDispatcher(new Agent({ ... })).
Production deployment
Pick the right tool
| Scenario | Recommendation |
|---|---|
| New code, Node 18+ | Built-in fetch — zero dep |
| CJS-locked legacy | node-fetch@2 |
| Heavy HTTP usage, need retries/streams/pooling | undici (lower level) or got |
| Cross-runtime (Node + browser + Workers) | ky or the built-in |
Memory: don't buffer large responses
await res.text() and await res.json() buffer the entire body. For large bodies, stream:
import { pipeline } from "node:stream/promises";
import { Readable } from "node:stream";
await pipeline(Readable.fromWeb(res.body as any), destinationStream);
Edge runtime compatibility
node-fetch is Node-only. For Cloudflare Workers, Vercel Edge, Deno, Bun — use the platform fetch. Universal modules should write code against globalThis.fetch and only import "node-fetch" as a fallback for old Node.
Migration to built-in fetch
// Before
import fetch from "node-fetch";
// After (Node 18+)
// nothing — fetch is global
For mixed codebases, a polyfill shim:
// src/lib/fetch.ts
let fetchImpl = globalThis.fetch;
if (!fetchImpl) {
fetchImpl = (await import("node-fetch")).default as any;
}
export const fetch = fetchImpl;
Performance tuning
Use built-in fetch (undici) for max throughput
undici is materially faster than node-fetch — measured at 2-3× higher throughput for the same workload. If perf matters, switch.
Connection pooling
Both node-fetch and built-in fetch benefit from explicit pooling. Built-in:
import { Agent, setGlobalDispatcher } from "undici";
setGlobalDispatcher(new Agent({ connections: 100, pipelining: 1 }));
node-fetch:
import { HttpsAgent } from "agentkeepalive";
const agent = new HttpsAgent({ maxSockets: 100 });
fetch(url, { agent });
Avoid res.text() then JSON.parse
await res.json() is one pass; await res.text() then JSON.parse(text) is two. For body sizes >1KB the difference compounds.
Streams beat buffers for big payloads
Anything over ~10MB should stream. await res.text() blocks the event loop while it accumulates.
Version migration guide
v2 → v3 — the big break
This is the migration most teams confront, and the reason many stay on v2.
| Change | v2 | v3 |
|---|---|---|
| Module format | CJS | ESM only |
res.body | Node Readable | Web ReadableStream |
timeout option | Supported | Removed (use AbortController) |
request.headers | Headers instance | Headers instance (same) |
| Node baseline | ≥4 | ≥12.20 |
// v2
const fetch = require("node-fetch");
const res = await fetch(url, { timeout: 5000 });
// v3 (ESM-only)
import fetch from "node-fetch";
const res = await fetch(url, { signal: AbortSignal.timeout(5000) });
// v3 in CJS — dynamic import
const fetch = (await import("node-fetch")).default;
v3 → built-in fetch (Node 18+)
// v3
import fetch from "node-fetch";
// built-in (Node 18+)
// just use global `fetch`
Differences:
| Behaviour | node-fetch v3 | built-in (undici) |
|---|---|---|
res.body | Web ReadableStream | Web ReadableStream |
agent option | Node http.Agent | undici Dispatcher (setGlobalDispatcher) |
| Timeouts | AbortController | AbortController or AbortSignal.timeout |
| Proxy | agent: new HttpsProxyAgent(...) | undici ProxyAgent |
| Cookies | Manual | Manual |
| HTTP/2 | No | Yes (opt-in) |
| Performance | Pure JS | Native-ish (undici) — 2-3× faster |
The migration is usually mechanical — remove the import, swap agent for dispatcher if you used pooling.
Stuck on v2 in CJS?
Pin and move on:
"dependencies": { "node-fetch": "^2.7.0" }
v2 still gets security backports. Migration to ESM unlocks v3 or the built-in — that's a bigger project.
Security considerations
node-fetch has had a few CVEs over the years; all patched in current versions.
| CVE | Year | Notes |
|---|---|---|
| CVE-2022-0235 | 2022 | Forwarded secure headers on redirect — fixed in 2.6.7 and 3.1.1 |
| CVE-2020-15168 | 2020 | maxSize redirect handling — fixed in 2.6.1 |
Rules:
- Pin
node-fetchto^2.7.0(v2) or^3.3.0(v3) or newer. Older versions leak auth headers on redirect. redirect: "manual"when you don't trust the response — prevents automatic redirect to attacker-controlled hosts.- SSRF mitigation: validate URLs server-side before fetching. Never
fetch(req.body.url)directly — attackers point at internal hosts (http://localhost:6379, AWS metadatahttp://169.254.169.254). - Cookie jars don't exist by default. This is actually safer for server-to-server use; just don't accidentally pair with a permissive jar.
- TLS cert validation is on by default. Don't set
rejectUnauthorized: falsein the agent — that disables verification.
Testing & CI integration
import { describe, it, expect } from "vitest";
import nock from "nock";
import fetch from "node-fetch";
describe("user api", () => {
it("fetches a user", async () => {
nock("https://api.example.com").get("/users/1").reply(200, { id: 1, name: "Alice" });
const res = await fetch("https://api.example.com/users/1");
const user = await res.json();
expect(user).toEqual({ id: 1, name: "Alice" });
});
});
Output: nock intercepts requests at the http level — works with node-fetch, axios, and the built-in fetch (Node 21+ via MockAgent).
For the built-in fetch, use undici's MockAgent:
import { MockAgent, setGlobalDispatcher } from "undici";
const mock = new MockAgent();
setGlobalDispatcher(mock);
mock.get("https://api.example.com").intercept({ path: "/users/1" }).reply(200, { id: 1 });
Ecosystem integrations
| Tool | Integration |
|---|---|
form-data | Multipart bodies — pair for uploads |
tough-cookie | Cookie jar — manual integration |
https-proxy-agent | Proxy support via agent: |
agentkeepalive | HTTP keep-alive pool |
nock | HTTP mocking for tests |
msw | Service-Worker-style request interception |
next.js | Server-side fetches; on Node 18+, drop node-fetch and use built-in |
vercel functions | Use built-in fetch — already there |
langchain / openai (legacy SDKs) | Older versions pulled node-fetch; new versions use built-in |
Troubleshooting common errors
Error [ERR_REQUIRE_ESM]: require() of ES Module ...— v3 in CJS. Pin to^2or migrate to ESM.fetch is not defined— Node ≤16. Either upgrade Node, polyfill withnode-fetch, or useundicidirectly.res.body.pipe is not a function— v3 / built-in fetch:res.bodyis web stream, not Node stream. UseReadable.fromWeb(res.body).AbortError: The operation was aborted—AbortController.abort()fired (your timeout or cancellation). Expected; handle in catch.UND_ERR_SOCKET(built-in fetch) — undici socket-level error. Usually a transient network issue; add retries.- Request hangs forever — no timeout. Use
AbortSignal.timeout(ms)orAbortController. - TS:
Cannot find name 'fetch'— TS lib doesn't include DOM. Add"lib": ["ES2022", "DOM"]totsconfig.json, or@types/node ≥18brings fetch types.
When NOT to use this
- You're on Node ≥18. Use the built-in global
fetch. Zero install, same API, faster. - You need retries, hooks, streams baked in. Use got — production-grade HTTP for Node.
- You're on Workers / Edge / Deno / Bun. Use platform
fetch—node-fetchis Node-only. - You're starting a new project in 2026. There's almost no reason to install
node-fetch. Either built-in fetch orundicidirectly. - You need HTTP/2 or HTTP/3. Use
undicidirectly. - You want axios's interceptors and transformers. Use
axios.node-fetchis intentionally minimal.
See also
- JavaScript: fetch — built-in fetch API on Node 18+ and browsers
- Concept: HTTP — the underlying protocol
- JavaScript: node runtime — undici, http.Agent, AbortController