<!-- PkgPulse AI-readable guide source -->
<!-- Canonical: https://www.pkgpulse.com/guides/got-vs-undici-vs-node-fetch-http-clients-nodejs-2026 -->
<!-- Raw Markdown: https://www.pkgpulse.com/guides/got-vs-undici-vs-node-fetch-http-clients-nodejs-2026/raw.md -->
<!-- Source path: content/guides/got-vs-undici-vs-node-fetch-http-clients-nodejs-2026.mdx -->

---
og_image: "/images/guides/got-vs-undici-vs-node-fetch-http-clients-nodejs-2026.webp"
title: "got vs undici vs node-fetch (2026)"
description: "Compare got, undici, and node-fetch for HTTP requests in Node.js. Performance, streaming, proxy support, TypeScript, HTTP/2, when to use native fetch vs."
date: "2026-03-09"
author: "PkgPulse Team"
tags: ["nodejs", "typescript", "api", "developer-tools"]
---

## 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.fetch` is built on undici — use it for most cases
- got v14 is ESM-only — use `got@12` for CommonJS or switch to got v14 with ESM
- undici's `Pool` and `Client` give 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:

```typescript
// 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](https://github.com/nodejs/undici) — Node.js's official HTTP client, part of the core:

### Basic usage

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

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

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

```typescript
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](https://github.com/sindresorhus/got) — feature-rich HTTP client with retry and pagination:

### Basic usage

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

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

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

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

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

```typescript
// 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 `fetch` instead
- 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:

```typescript
// 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 →](https://www.pkgpulse.com)*

*See also: [got vs node-fetch](/compare/got-vs-node-fetch) and [Axios vs node-fetch](/compare/axios-vs-node-fetch), [better-sqlite3 vs libsql vs sql.js](/guides/better-sqlite3-vs-libsql-vs-sql-js-sqlite-nodejs-2026).*
