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

bash
# 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.

bash
# 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.

bash
# 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 broke require("node-fetch") and made it incompatible with most existing CJS code. Use only if your codebase is ESM-native.
  • There's no v4 planned. The maintainers have stated node-fetch is 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 fetch global

Peer dependencies & extras

PackagePurpose
@types/node-fetchTS declarations for v2 (v3 bundles types).
form-dataMultipart bodies — pair with node-fetch for file uploads.
https-proxy-agentHTTPS proxy support — pass via agent: option.
tough-cookieCookie jar — node-fetch doesn't store cookies; pair manually.
abort-controllerPolyfill for Node <14.17 (Node 15+ has it built in).

Alternatives

LibraryTrade-off
Built-in fetch (Node 18+)Zero-dep, modern, undici-backed, fast. Same API. Pick this first.
undiciThe library behind Node's built-in fetch. Also exports lower-level Client/Pool/Dispatcher. Pick for max performance or HTTP/2.
axiosInterceptors, transformers, automatic JSON serialization, cancellation tokens, broader API. Pick if you need a feature-rich HTTP client.
gotStreams, retries, pagination, cookie jars built in. Pick for production-grade HTTP with retries.
kyModern minimal fetch wrapper (~3 KB) with retries and timeout. Works on Node + browsers + Workers. Pick for cross-runtime apps.
isomorphic-fetchOlder universal wrapper. Mostly redundant now.
cross-fetchUsed to be the cross-platform glue. Mostly redundant on Node 18+.

Common gotchas

  1. node-fetch v3 is ESM-only. require("node-fetch") throws on v3. Either upgrade your codebase to ESM, use dynamic import(), or pin v2.
  2. Built-in fetch and node-fetch are not identical. Response.body is a Node Readable in node-fetch v2; in node-fetch v3 and built-in fetch, it's a ReadableStream (web standard). Stream consumption code differs.
  3. No automatic cookie jar. Unlike axios/got/fetch in a browser, node-fetch doesn't store cookies between requests. Pair with tough-cookie if needed.
  4. AbortController polyfill needed for Node <14.17. Node 15+ has it global; older Nodes need npm install abort-controller.
  5. Timeouts aren't first-class. Use AbortController + setTimeout. Don't rely on timeout option in v3 (removed).
  6. agent: is Node-only. The built-in fetch uses undici's dispatcher; node-fetch uses Node's http.Agent. Migration between the two means rewriting any agent-based code.

Real-world recipes

Basic GET

typescript
// 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:

json
{ "id": 1, "name": "Alice", "email": "alice@example.com" }

On Node 18+ — just drop the import:

typescript
const res = await fetch("https://api.example.com/users/1");
const user = await res.json();

Same code, zero dep.

POST JSON

typescript
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:

json
{ "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)

typescript
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:

text
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):

typescript
import { Readable } from "node:stream";

await pipeline(Readable.fromWeb(res.body as any), fs.createWriteStream("./out.zip"));

AbortController — timeouts and cancellation

typescript
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):

typescript
const res = await fetch(url, { signal: AbortSignal.timeout(5000) });

Concurrent requests with Promise.all

typescript
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:

text
3 files fetched

For very large parallel batches, throttle with p-limit to avoid exhausting connections.

Custom HTTP agent (connection pooling, keep-alive)

typescript
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

ScenarioRecommendation
New code, Node 18+Built-in fetch — zero dep
CJS-locked legacynode-fetch@2
Heavy HTTP usage, need retries/streams/poolingundici (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:

typescript
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

typescript
// Before
import fetch from "node-fetch";

// After (Node 18+)
// nothing — fetch is global

For mixed codebases, a polyfill shim:

typescript
// 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:

typescript
import { Agent, setGlobalDispatcher } from "undici";
setGlobalDispatcher(new Agent({ connections: 100, pipelining: 1 }));

node-fetch:

typescript
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.

Changev2v3
Module formatCJSESM only
res.bodyNode ReadableWeb ReadableStream
timeout optionSupportedRemoved (use AbortController)
request.headersHeaders instanceHeaders instance (same)
Node baseline≥4≥12.20
typescript
// 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+)

typescript
// v3
import fetch from "node-fetch";

// built-in (Node 18+)
// just use global `fetch`

Differences:

Behaviournode-fetch v3built-in (undici)
res.bodyWeb ReadableStreamWeb ReadableStream
agent optionNode http.Agentundici Dispatcher (setGlobalDispatcher)
TimeoutsAbortControllerAbortController or AbortSignal.timeout
Proxyagent: new HttpsProxyAgent(...)undici ProxyAgent
CookiesManualManual
HTTP/2NoYes (opt-in)
PerformancePure JSNative-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:

json
"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.

CVEYearNotes
CVE-2022-02352022Forwarded secure headers on redirect — fixed in 2.6.7 and 3.1.1
CVE-2020-151682020maxSize redirect handling — fixed in 2.6.1

Rules:

  1. Pin node-fetch to ^2.7.0 (v2) or ^3.3.0 (v3) or newer. Older versions leak auth headers on redirect.
  2. redirect: "manual" when you don't trust the response — prevents automatic redirect to attacker-controlled hosts.
  3. SSRF mitigation: validate URLs server-side before fetching. Never fetch(req.body.url) directly — attackers point at internal hosts (http://localhost:6379, AWS metadata http://169.254.169.254).
  4. 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.
  5. TLS cert validation is on by default. Don't set rejectUnauthorized: false in the agent — that disables verification.

Testing & CI integration

typescript
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:

typescript
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

ToolIntegration
form-dataMultipart bodies — pair for uploads
tough-cookieCookie jar — manual integration
https-proxy-agentProxy support via agent:
agentkeepaliveHTTP keep-alive pool
nockHTTP mocking for tests
mswService-Worker-style request interception
next.jsServer-side fetches; on Node 18+, drop node-fetch and use built-in
vercel functionsUse 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 ^2 or migrate to ESM.
  • fetch is not defined — Node ≤16. Either upgrade Node, polyfill with node-fetch, or use undici directly.
  • res.body.pipe is not a function — v3 / built-in fetch: res.body is web stream, not Node stream. Use Readable.fromWeb(res.body).
  • AbortError: The operation was abortedAbortController.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) or AbortController.
  • TS: Cannot find name 'fetch' — TS lib doesn't include DOM. Add "lib": ["ES2022", "DOM"] to tsconfig.json, or @types/node ≥18 brings 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 fetchnode-fetch is Node-only.
  • You're starting a new project in 2026. There's almost no reason to install node-fetch. Either built-in fetch or undici directly.
  • You need HTTP/2 or HTTP/3. Use undici directly.
  • You want axios's interceptors and transformers. Use axios. node-fetch is intentionally minimal.

See also