Skip to main content

got vs undici vs node-fetch: HTTP Clients in Node.js Beyond Axios (2026)

·PkgPulse Team

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

PackageWeekly DownloadsHTTP/2RetryStreamsProxyCookies
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

Featureundicigotnode-fetchnative fetch
Bundle/cost0KB (built-in)~200KB~50KB0KB
Retry✅ Built-in
Hooks
Pagination
Connection pooling✅ Pool/Client
HTTP/2
Proxy
Streaming
Test mocking✅ MockAgent
ESM + CJS✅ ESM-only v14built-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.

Compare HTTP client and networking packages on PkgPulse →

Comments

Stay Updated

Get the latest package insights, npm trends, and tooling tips delivered to your inbox.