undici
Undici is the HTTP/1.1 client the Node.js project wrote from scratch, and it is what powers the fetch() built into Node since v18. Installing it as a module gets you the newest version of that engine plus the low-level APIs the built-in fetch hides: request, stream, pipeline, and dispatch, along with connection pools, pipelining, interceptors for retry and caching, ProxyAgent and Socks5Agent for proxying, and MockAgent for network-free tests. It has zero runtime dependencies and, in the project's own benchmarks, undici.request runs several times faster than node-fetch and clearly ahead of axios and got.
This is the engine behind Node's own fetch, maintained inside the nodejs org, and the fastest mainstream HTTP client for Node by its own published benchmarks. Install it when you need the pooling, mocking, proxy, or interceptor controls the built-in fetch does not expose; if built-in fetch covers you, adding the module buys you little.
Use it if
- You need connection pooling control, HTTP/1.1 pipelining, or interceptors (retry, cache, DNS) that Node's built-in fetch does not expose
- Service-to-service HTTP performance matters; the project's benchmarks put undici.request at roughly 3.5x node-fetch on the same workload
- You need MockAgent to intercept requests in tests without touching the network, or ProxyAgent/Socks5Agent for outbound proxying
- You want a newer fetch implementation than the one bundled with your Node runtime (check process.versions.undici to see what you have)
- Simple GET/POST calls are all you do; Node's built-in fetch is already undici under the hood and needs zero dependencies
- Your code must also run in browsers, Deno, or edge runtimes; undici is Node-only, and the portable answer is the standard fetch you already have
- You want a batteries-included client; got and ky ship retries, hooks, and pagination as defaults, while undici's request API is deliberately low-level and you assemble those behaviors from interceptors yourself
- You are on an older Node LTS: undici 8 requires Node >= 22.19.0, which rules it out for a lot of production fleets still on 20
- Your team will mix undici's fetch/FormData with the global ones; the README explicitly warns that mixing implementations breaks request bodies, and it is an easy mistake to make
Setup reality
npm i undici is clean: zero runtime dependencies, no build step, no peer deps. The catches are elsewhere. Version 8 requires Node >= 22.19.0, a real cliff if you are on Node 20 or 22 pre-.19. The request API hands you a body stream that you must consume (or call .dump()) even on error paths, otherwise the connection is not released back to the pool and things stall under load. Composing dispatchers (Agent, compose, interceptors) is its own mental model and the docs assume you will read them. Error shapes also differ between undici.fetch, which wraps failures in TypeError per the spec, and undici.request, which throws undici's own error classes.
Patterns
GET a JSON response with request()get-json
import { request } from 'undici'
const { statusCode, headers, body } = await request('https://api.example.com/data')
if (statusCode !== 200) {
await body.dump()
throw new Error(`unexpected status ${statusCode}`)
}
const data = await body.json()Always consume or dump() the body, even on error paths, or the connection never returns to the pool.
POST a JSON payloadpost-json
import { request } from 'undici'
const { statusCode, body } = await request('https://api.example.com/items', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ name: 'widget', qty: 2 })
})
const created = await body.json()request() does not throw on 4xx/5xx like fetch does not; check statusCode yourself.
Use undici's fetch instead of the built-infetch-drop-in
import { fetch } from 'undici'
const res = await fetch('https://api.example.com/data')
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const data = await res.json()This gets you the latest fetch spec behavior instead of whatever undici version your Node binary bundled; check process.versions.undici to compare.
Tune the connection pool globallyconnection-pool
import { Agent, setGlobalDispatcher } from 'undici'
setGlobalDispatcher(new Agent({
connections: 128,
keepAliveTimeout: 10_000,
pipelining: 1
}))
// every undici request/fetch in the process now uses this poolsetGlobalDispatcher affects undici module calls; Node's built-in global fetch keeps its own bundled dispatcher.
Retry failed requests with RetryAgenthttp-retry
import { Agent, RetryAgent, request } from 'undici'
const agent = new RetryAgent(new Agent(), {
maxRetries: 3,
minTimeout: 500,
statusCodes: [429, 500, 502, 503, 504]
})
const { statusCode, body } = await request('https://api.example.com/flaky', {
dispatcher: agent
})By default only idempotent situations are safe to retry; think twice before adding POST to the retry set.
Send requests through an HTTP proxyproxy
import { ProxyAgent, request } from 'undici'
const proxy = new ProxyAgent('http://proxy.internal:8080')
const { body } = await request('https://api.example.com/data', {
dispatcher: proxy
})
const data = await body.json()ProxyAgent is per-request via dispatcher or global via setGlobalDispatcher; there is also a Socks5Agent for SOCKS proxies.
Mock HTTP calls in tests with MockAgentmock-testing
import { MockAgent, setGlobalDispatcher, request } from 'undici'
const mockAgent = new MockAgent()
mockAgent.disableNetConnect()
setGlobalDispatcher(mockAgent)
mockAgent
.get('https://api.example.com')
.intercept({ path: '/users/1', method: 'GET' })
.reply(200, { id: 1, name: 'ada' })
const { body } = await request('https://api.example.com/users/1')
console.log(await body.json()) // { id: 1, name: 'ada' }disableNetConnect() makes unmocked calls throw instead of silently hitting the real network; use it.
Set headers and body timeoutstimeouts
import { Agent, request } from 'undici'
const agent = new Agent({
headersTimeout: 10_000, // ms to wait for response headers
bodyTimeout: 30_000 // ms of body inactivity allowed
})
const { body } = await request('https://api.example.com/slow', {
dispatcher: agent
})bodyTimeout is an inactivity timeout between chunks, not a total-duration cap; combine with AbortSignal.timeout for a hard deadline.
Cancel a request with an AbortSignalabort-request
import { request } from 'undici'
try {
const { body } = await request('https://api.example.com/slow', {
signal: AbortSignal.timeout(5000)
})
const data = await body.json()
} catch (err) {
if (err.name === 'TimeoutError' || err.name === 'AbortError') {
// deadline hit
} else throw err
}AbortSignal.timeout gives a total-duration deadline, which the per-phase Agent timeouts alone do not.
Stream a large response bodystream-response
import { request } from 'undici'
import { createWriteStream } from 'node:fs'
import { pipeline } from 'node:stream/promises'
const { body } = await request('https://example.com/big-file.tar.gz')
await pipeline(body, createWriteStream('/tmp/big-file.tar.gz'))body is an async-iterable Node stream; piping it avoids buffering the whole payload in memory.
Cache responses with the cache interceptorhttp-cache
import { fetch, Agent, interceptors, setGlobalDispatcher } from 'undici'
import { cacheStores } from 'undici'
const client = new Agent().compose(interceptors.cache({
store: new cacheStores.MemoryCacheStore({ maxSize: 100 * 1024 * 1024 }),
methods: ['GET', 'HEAD']
}))
setGlobalDispatcher(client)
const res = await fetch('https://api.example.com/data') // cached per Cache-ControlCaching honors the server's Cache-Control/Expires headers; if the origin sends no cache headers, nothing gets cached.
Upload multipart form dataform-data-upload
import { fetch, FormData } from 'undici'
import { openAsBlob } from 'node:fs'
const form = new FormData()
form.set('file', await openAsBlob('./report.pdf'), 'report.pdf')
form.set('note', 'monthly report')
await fetch('https://api.example.com/upload', { method: 'POST', body: form })Keep fetch and FormData from the same implementation (both undici imports or both globals); mixing them is a documented failure mode.