TL;DR
undici is the fastest Node.js HTTP client — it's the official HTTP/1.1 client built into Node.js (the same library that powers global.fetch in Node.js 18+). got is the feature-rich option — retries, pagination helpers, streams, hooks, and the best API for complex HTTP client needs. node-fetch is largely obsolete in 2026 — it was a polyfill for browsers' fetch API, but Node.js 18+ ships fetch natively. For most cases: use fetch built-in. For complex clients with retry/pagination: got. For maximum throughput with connection pooling: undici.
Key Takeaways
- undici: ~30M weekly downloads — Node.js's official HTTP engine, fastest, powers native
fetch - got: ~20M weekly downloads — feature-rich, retry, hooks, pagination, streaming
- node-fetch: ~50M weekly downloads — largely legacy now that Node.js has built-in fetch
- Node.js 18+
global.fetchis built on undici — use it for most cases - got v14 is ESM-only — use
got@12for CommonJS or switch to got v14 with ESM - undici's
PoolandClientgive direct connection pool control for high-throughput servers
Download Trends
| Package | Weekly Downloads | HTTP/2 | Retry | Streams | Proxy | Cookies |
|---|---|---|---|---|---|---|
undici | ~30M | ✅ | ❌ | ✅ | ✅ | ✅ |
got | ~20M | ✅ | ✅ | ✅ | ✅ | ✅ |
node-fetch | ~50M | ❌ | ❌ | ✅ | ❌ | ❌ |
fetch (built-in) | built-in | ✅ | ❌ | ✅ | ❌ | ❌ |
When Native fetch is Enough
Node.js 18+ ships global.fetch — for most API calls, no library is needed:
// No import needed in Node.js 18+:
// GET:
const response = await fetch("https://registry.npmjs.org/react/latest")
if (!response.ok) throw new Error(`HTTP ${response.status}`)
const data = await response.json()
console.log(data.version) // "18.3.1"
// POST:
const result = await fetch("https://api.pkgpulse.com/alerts", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${process.env.API_KEY}`,
},
body: JSON.stringify({ packageName: "react", threshold: 20 }),
})
// With timeout (AbortSignal):
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), 5000)
try {
const res = await fetch("https://slow-api.example.com/data", {
signal: controller.signal,
})
const data = await res.json()
} catch (err) {
if (err.name === "AbortError") console.error("Request timed out")
} finally {
clearTimeout(timeout)
}
undici
undici — Node.js's official HTTP client, part of the core:
Basic usage
import { request } from "undici"
// Simple request:
const { statusCode, headers, body } = await request(
"https://registry.npmjs.org/react/latest"
)
const data = await body.json()
console.log(statusCode) // 200
console.log(data.version) // "18.3.1"
// Consume body as text:
const text = await body.text()
// Consume body as stream:
for await (const chunk of body) {
process.stdout.write(chunk)
}
// POST:
const { statusCode: status } = await request("https://api.pkgpulse.com/alerts", {
method: "POST",
headers: {
"content-type": "application/json",
authorization: `Bearer ${process.env.API_KEY}`,
},
body: JSON.stringify({ packageName: "react", threshold: 20 }),
})
Connection pooling (high throughput)
import { Pool, Client } from "undici"
// Pool — multiple connections to the same origin:
const pool = new Pool("https://registry.npmjs.org", {
connections: 10, // 10 concurrent connections
pipelining: 1, // HTTP pipelining
})
// All requests use the pool:
async function fetchPackages(names: string[]) {
return Promise.all(
names.map(async (name) => {
const { body } = await pool.request({ path: `/${name}/latest`, method: "GET" })
return body.json()
})
)
}
const packages = await fetchPackages(["react", "vue", "svelte", "solid-js"])
await pool.close()
// Client — single connection, maximum control:
const client = new Client("https://api.pkgpulse.com", {
keepAliveTimeout: 30_000,
keepAliveMaxTimeout: 60_000,
bodyTimeout: 10_000,
headersTimeout: 5_000,
})
Streaming
import { stream } from "undici"
import { createWriteStream } from "fs"
// Stream response directly to file (no buffering):
await stream(
"https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
{ method: "GET" },
({ statusCode }) => {
if (statusCode !== 200) throw new Error("Download failed")
return createWriteStream("lodash.tgz")
}
)
// Or using pipeline:
import { pipeline } from "undici"
import { createGzip } from "zlib"
await pipeline(
"https://example.com/large-dataset.json",
{ method: "GET" },
({ body }) => body.pipe(createGzip())
)
MockAgent for testing
import { MockAgent, setGlobalDispatcher } from "undici"
// Mock undici (and global fetch) in tests:
const mockAgent = new MockAgent()
setGlobalDispatcher(mockAgent)
const mockPool = mockAgent.get("https://registry.npmjs.org")
mockPool.intercept({
path: "/react/latest",
method: "GET",
}).reply(200, {
name: "react",
version: "18.3.1",
})
// Now fetch uses the mock:
const res = await fetch("https://registry.npmjs.org/react/latest")
const data = await res.json()
console.log(data.version) // "18.3.1" (mocked)
got
got — feature-rich HTTP client with retry and pagination:
Basic usage
import got from "got"
// GET with auto-JSON:
const data = await got("https://registry.npmjs.org/react/latest").json<NpmPackage>()
console.log(data.version)
// POST:
const response = await got.post("https://api.pkgpulse.com/alerts", {
json: { packageName: "react", threshold: 20 },
}).json<Alert>()
// With options:
const result = await got("https://api.pkgpulse.com/packages", {
searchParams: { q: "react", limit: 10 },
headers: { Authorization: `Bearer ${process.env.API_KEY}` },
timeout: { request: 5000 },
}).json<Package[]>()
Retry with exponential backoff
import got from "got"
const client = got.extend({
prefixUrl: "https://api.pkgpulse.com",
headers: { Authorization: `Bearer ${process.env.API_KEY}` },
retry: {
limit: 3,
methods: ["GET", "PUT", "HEAD", "DELETE"],
statusCodes: [408, 413, 429, 500, 502, 503, 504],
errorCodes: ["ETIMEDOUT", "ECONNRESET", "ECONNREFUSED", "EPIPE"],
calculateDelay: ({ retryCount }) => retryCount * 1000, // 1s, 2s, 3s
},
})
// This request automatically retries on failure:
const packages = await client.get("packages").json<Package[]>()
Hooks
import got from "got"
const client = got.extend({
hooks: {
beforeRequest: [
(options) => {
console.log(`→ ${options.method} ${options.url}`)
options.headers["X-Request-Id"] = crypto.randomUUID()
},
],
afterResponse: [
(response) => {
console.log(`← ${response.statusCode} ${response.requestUrl}`)
return response
},
],
beforeRetry: [
({ retryCount, error }) => {
console.log(`Retry ${retryCount} after: ${error.message}`)
},
],
beforeError: [
(error) => {
const { response } = error
if (response?.statusCode === 401) {
error.name = "UnauthorizedError"
}
return error
},
],
},
})
Pagination
import got from "got"
// got has built-in pagination support:
const allPackages: Package[] = []
for await (const page of got.paginate<Package[]>(
"https://api.pkgpulse.com/packages",
{
searchParams: { limit: 100 },
pagination: {
transform: (response) => response.json<{ packages: Package[]; cursor: string }>()
.then((data) => data.packages),
paginate: ({ response }) => {
const data = response.body as any
if (!data.cursor) return false // No more pages
return { searchParams: { cursor: data.cursor, limit: 100 } }
},
},
}
)) {
allPackages.push(...page)
}
Streams
import got from "got"
import { createWriteStream } from "fs"
import { pipeline } from "stream/promises"
// Download with progress:
const download = got.stream("https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz")
download.on("downloadProgress", ({ transferred, total, percent }) => {
process.stdout.write(`\rDownloading: ${Math.round(percent * 100)}%`)
})
await pipeline(download, createWriteStream("lodash.tgz"))
node-fetch (Legacy)
// node-fetch was needed before Node.js 18 had global fetch:
import fetch from "node-fetch"
// In Node.js 18+, this is equivalent to just using global fetch:
const res = await fetch("https://api.example.com/data")
const data = await res.json()
// Migrate from node-fetch to native fetch:
// 1. Remove: import fetch from "node-fetch"
// 2. Remove the package: npm uninstall node-fetch
// 3. native fetch works the same for most cases
// The only remaining reasons to use node-fetch v3:
// - You need it in Node.js 16 or earlier
// - You depend on its specific stream handling behavior
// - Some libraries internally use node-fetch and haven't updated
Feature Comparison
| Feature | undici | got | node-fetch | native fetch |
|---|---|---|---|---|
| Bundle/cost | 0KB (built-in) | ~200KB | ~50KB | 0KB |
| Retry | ❌ | ✅ Built-in | ❌ | ❌ |
| Hooks | ❌ | ✅ | ❌ | ❌ |
| Pagination | ❌ | ✅ | ❌ | ❌ |
| Connection pooling | ✅ Pool/Client | ❌ | ❌ | ❌ |
| HTTP/2 | ✅ | ✅ | ❌ | ✅ |
| Proxy | ✅ | ✅ | ❌ | ❌ |
| Streaming | ✅ | ✅ | ✅ | ✅ |
| Test mocking | ✅ MockAgent | ✅ | ❌ | ❌ |
| ESM + CJS | ✅ | ✅ ESM-only v14 | ✅ | built-in |
When to Use Each
Use native fetch (no package) if:
- Simple API calls in Node.js 18+ — it's built in and works great
- You want zero dependencies and browser-compatible code
- Basic GET/POST without complex retry or pagination logic
Choose undici if:
- Maximum throughput — building proxies, scrapers, or high-traffic servers
- You need direct connection pool control (
Pool,Client) - Testing HTTP calls via
MockAgent(works for both undici and native fetch) - You're using undici's streaming for large response bodies
Choose got if:
- Complex HTTP clients with retry, pagination, and hooks
- You need the most feature-complete library (similar to Python's
requests) - Building SDK wrappers around third-party APIs
- You want automatic progress events for large downloads
Avoid node-fetch if:
- You're on Node.js 18+ — use native
fetchinstead - Still on Node.js 16 — it's the only valid use case remaining
Methodology
Download data from npm registry (weekly average, February 2026). Feature comparison based on undici v6.x, got v14.x, and node-fetch v3.x.
TypeScript Integration and Type Inference
The TypeScript experience differs meaningfully across these HTTP clients, and the differences compound as codebase complexity grows. Native fetch has been typed in TypeScript's lib.dom.d.ts for years, but the Node.js-specific type definitions required the @types/node package's dom-globals shim until Node.js 18's fetch stabilized. As of TypeScript 5.x targeting Node.js 18+, native fetch is typed without any additional packages. The response body methods (response.json(), response.text(), response.arrayBuffer()) return Promise<any>, Promise<string>, and Promise<ArrayBuffer> respectively — the json() return type is any, which means TypeScript cannot verify that the parsed response matches an expected interface without explicit type assertion or a generic wrapper.
got's type system is stronger for generic typed responses. got('url').json<Package[]>() returns Promise<Package[]> with full type inference, making the fetched data type-safe without casting. This generic typing flows through got's pagination API as well — got.paginate<Package>() produces an async iterator of Package objects. got's HTTPError type carries error.response.body as the raw response body string and error.response.statusCode as a number — both fully typed, making structured error logging straightforward. For teams building internal TypeScript SDKs or server-to-server clients where type safety across the HTTP boundary matters, got's generic API eliminates a class of runtime type errors that would otherwise require manual validation on every response.
Error Handling and Retry Patterns
The most important operational difference between these HTTP clients is how they handle failures. Native fetch and undici throw on network errors but return non-throwing responses for 4xx and 5xx status codes — you must explicitly check response.ok or the status code. This is a common source of bugs where developers forget the check and silently process error responses as if they were successful ones. A disciplined wrapper function around native fetch that throws on non-2xx responses is the minimum viable safety net for production code.
got's error model is more opinionated and more helpful. It throws RequestError for network failures and HTTPError for non-2xx responses by default. The error.response.body and error.response.statusCode are available on the caught error, making structured logging straightforward. Combined with got's built-in retry, this means transient 503s and 429s are retried automatically with exponential backoff before surfacing as errors — a complete resilience pattern with no additional code.
For undici's low-level request() function, you must implement retry logic yourself. This is intentional: undici is a building block for higher-level libraries and frameworks (Fastify uses undici internally), not a batteries-included client. When building a high-throughput service with undici's connection pooling, you typically wrap it in a thin retry layer using something like async-retry or a manual exponential backoff loop.
Proxy and Authentication Patterns
Corporate network environments and security-sensitive deployments frequently require HTTP requests to route through proxy servers, and the three clients handle this differently. Native fetch in Node.js does not natively support HTTP_PROXY or HTTPS_PROXY environment variables — this is a known gap compared to the request/axios era. The undici-proxy-agent package adds proxy support to undici (and therefore native fetch, since they share the dispatcher system), configuring it via setGlobalDispatcher(new ProxyAgent(proxyUrl)). Once set, all undici and native fetch requests route through the proxy transparently. got supports proxy configuration through the agent option using the hpagent package, which provides proxy-aware agents for both HTTP and HTTPS destinations.
For API authentication patterns, got's extend() system enables clean bearer token injection without request-level boilerplate. Creating a base client with beforeRequest hooks that inject Authorization headers — and afterResponse hooks that detect 401 responses and trigger a token refresh — implements OAuth 2.0 token refresh logic in roughly 20 lines of configuration code. This pattern is harder to implement cleanly with native fetch, which has no hook system, requiring either a wrapper function that handles the retry logic imperatively or a custom fetch implementation that intercepts the native function. The ofetch library (from the UnJS ecosystem) adds hook support on top of native fetch for teams that want both browser compatibility and request lifecycle hooks without importing got.
Building SDK Wrappers Around Third-Party APIs
When building an SDK wrapper — a typed client for a REST API that you'll publish as a package or reuse across services — the choice between got and native fetch becomes significant. got's .extend() method creates a configured client that can be extended further by consumers:
// Base client with shared config
const baseClient = got.extend({
prefixUrl: 'https://api.npmjs.org',
timeout: { request: 10_000 },
retry: { limit: 3 },
headers: { 'User-Agent': 'my-npm-client/1.0' },
})
// Extended client for download stats
export const statsClient = baseClient.extend({
prefixUrl: 'https://api.npmjs.org/downloads',
})
With native fetch, you'd build the same composability manually through factory functions and config objects. This is fine for a small API wrapper but becomes verbose as the number of endpoints grows. The got approach is less code and more declarative. The tradeoff is bundle size: got at ~200KB is appropriate for Node.js server-side code but is never the right choice for browser bundles where native fetch is available at zero cost.
For edge deployments on Cloudflare Workers or Vercel Edge, native fetch is the only viable option — got's dependency on Node.js internals makes it incompatible. undici technically works in some edge environments when they implement a compatible Node.js subset, but the correct edge-native pattern is to use the platform's fetch directly, optionally enhanced with a thin retry wrapper using async-retry or p-retry.
Compare HTTP client and networking packages on PkgPulse →
See also: got vs node-fetch and Axios vs node-fetch, better-sqlite3 vs libsql vs sql.js.