got vs undici vs node-fetch: HTTP Clients in Node.js Beyond Axios (2026)
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.