Get E-Book
HTTP Servers, Clients & Proxies

HTTP Clients, fetch, and Undici

Ishtmeet Singh @ishtms/June 10, 2026/40 min read
#nodejs#http#fetch#undici#http-client

http.request() gives you the lower-level outbound HTTP path in Node. It is the API you use when you want to control the request stream yourself instead of letting a convenience wrapper finish things for you.

The call looks tiny. A lot of work starts underneath.

js
import http from "node:http";

const req = http.request("http://localhost:3000/users", res => {
  res.resume();
});

req.end();

http.request() creates an http.ClientRequest. At first, that object represents an outbound request that is still being prepared. It has headers, a method, a path, an agent choice, socket state, and maybe a request body waiting to be written.

The callback does not run when the request object is created. It runs later, after Node has received and parsed the response headers from the server. At that point, Node gives your callback an http.IncomingMessage, which represents the response.

res.resume() drains the response body. That line looks boring, but it is important. A response body that nobody reads can keep the socket tied to that request. If the socket cannot be safely reused, your pool has fewer connections available for later requests.

req.end() finishes the outbound request stream. For a request with no body, it tells Node that there are no more request bytes coming. Node may prepare headers earlier. It may even assign a socket earlier. But from your code's point of view, the request stream stays open until you end it.

The first thing to remember is simple - creating the request object and finishing the outbound request are two separate steps.

The Client Request Path

http.request() accepts a URL string, a URL object, or an options object. Node normalizes those inputs into the pieces it needs for an outbound request - protocol, hostname, port, path, method, headers, auth, local address, DNS lookup behavior, agent choice, and socket settings.

The path and query string become the HTTP request target for a normal origin request. The hostname and port are used by the agent to find or create a connection for that destination.

The object returned by http.request() is an http.ClientRequest. This is the handle your code uses for the outbound exchange. It is a writable stream, so you can write a body into it. It also emits lifecycle events such as socket, response, continue, information, timeout, error, and close.

The request moves through several layers -

text
URL/options -> ClientRequest
ClientRequest -> http.Agent
Agent -> DNS and socket acquisition
socket -> HTTP request bytes
parser -> IncomingMessage
IncomingMessage -> body consumer

The agent handles socket acquisition. If a reusable socket already exists for the destination, the request can use it. If all matching sockets are busy and the agent is allowed to open another connection, it creates one. If the pool is already at its limit, the request waits in the agent's pending queue.

The request object can exist before DNS finishes, before a TCP connection exists, and before any request bytes have reached the kernel send buffer. During part of that time, your code can still set headers, write body chunks into the request stream, and attach event listeners.

Node normalizes options early. A string URL is parsed. A URL object is read for protocol, hostname, port, username, password, pathname, and search. An options object can override parts of that URL. Node then builds the request path from pathname plus search, chooses a default method when needed, prepares headers, and selects the agent. The Host header comes from the destination unless your code supplies one.

Many client bugs begin in that setup step. The URL path belongs in the HTTP request target. The hostname belongs in connection acquisition. A proxy request may use an absolute URL in the request line, but ordinary origin requests use only the path and query. If you mix those values by hand, the TCP connection can go to one host while the HTTP message names another host.

Here is a normal GET request built from a URL, with options overriding the method and headers -

js
const req = http.request(new URL("http://api.local/users?active=1"), {
  method: "GET",
  headers: { accept: "application/json" }
});

req.end();

The URL gives Node the origin and request target. The options object changes the method and headers. Creating this object does not mean a socket already exists.

Headers stay mutable until Node commits them to the wire. req.setHeader(), req.removeHeader(), and req.getHeader() work during that early window. The first body write, req.end(), or req.flushHeaders() can close the window. After headers have been written, changing a header would mean changing bytes that may already be gone, so Node rejects or ignores late mutation depending on the exact method used.

The request body can also wait inside Node before a socket is ready. If your code calls req.write() immediately after creating the request, Node can buffer that body data while DNS and connection setup are still pending. Once a socket is assigned, Node writes the serialized headers and buffered body chunks in order. The return value from write() still needs attention because user-space buffering can grow before the kernel sees a single byte.

When Node assigns a socket, the request emits socket. Most application clients do not need this event. It becomes useful when you want to inspect req.socket.remoteAddress, source port behavior, socket reuse, or timing around socket attachment.

Node serializes headers when the request starts flushing. A body write can trigger that. req.end() can trigger it too. req.flushHeaders() exists for the less common case where headers should be sent before the body is ready.

Here is a simple POST where Node can use chunked transfer coding because the final body size was not declared -

js
const req = http.request(url, {
  method: "POST",
  headers: { "content-type": "application/json" }
});

req.write(JSON.stringify({ name: "Ada" }));
req.end();

Since there is no Content-Length, Node can frame the request body with HTTP/1.1 chunked transfer coding. If you set Content-Length, Node sends a fixed-length body and your writes need to match that length.

The response event is the normal handoff from writing the request to reading the response. It fires after Node has parsed the final response status line and headers. The event argument is an http.IncomingMessage. The same class name appears on the server side, but here it represents the response from the upstream server.

js
const req = http.request(url, res => {
  console.log(res.statusCode);
  res.setEncoding("utf8");
  res.on("data", chunk => process.stdout.write(chunk));
});

req.end();

The callback runs when status and headers are ready. The response body may still be arriving. Body chunks are future reads from the response stream.

Request errors attach to the request object. DNS failures, connection refusal, resets before a valid response, parser errors, request stream failures, and timeout handling can show up there. A response can also start cleanly and then fail while the body is being read. Client code needs error handling around both the request side and the response side.

The client request lifecycle is easier to follow as a chain of states -

text
created -> assigned socket -> headers sent
body writing -> request ended
response headers parsed -> body read
complete -> socket reused or closed

The timing changes from request to request. A reused socket can skip connection setup. A POST may spend time uploading before the final response arrives. A server may send 100 Continue before the body. A stale pooled socket may fail as soon as Node writes the first byte.

http.request() does not hide these states. That is why it is useful, and also why it asks more from you. You get the real stream and event model. You also own the details.

Under the JavaScript object, several pieces of state move together. The ClientRequest stores outgoing headers and body state. The agent stores pool state. The socket stores transport state. The HTTP parser stores response parsing state.

A reused socket already has transport state. It may also have bytes waiting in the kernel receive buffer. The HTTP parser has to be attached before those bytes can become an IncomingMessage. If the socket was idle in the pool, Node marks it active, attaches it to the new request, and starts writing the next request. request.reusedSocket tells you that the request used a pooled socket, which is useful when debugging stale keep-alive connections.

A new socket starts with DNS and TCP work. The request waits while that work finishes. If DNS fails, no HTTP bytes exist. If TCP connection fails, no HTTP response exists. If the socket connects and resets during the first write, the request has already been created, but response parsing has not started.

Once response bytes arrive, llhttp parses the status line and headers. Node cannot safely emit the final response event until a complete final response header section has been parsed. After that event, the parser continues feeding body bytes into the IncomingMessage stream until the HTTP framing rules say the body is finished.

Connection reuse depends on that body reaching a valid end. If the response body is close-delimited, the connection has to close to mark the end. If the response has a correct Content-Length or a chunked terminator, the parser can find the end while the connection stays open. Then the agent can put the socket back into the free pool.

That is why reading the response body is part of HTTP client correctness. It is not a stream footnote. It decides whether the connection can be reused.

http.get() And Early Response Events

http.get() is a small wrapper around http.request(). It sets the method to GET by default and calls req.end() for you.

js
http.get("http://localhost:3000/health", res => {
  console.log(res.statusCode);
  res.resume();
});

The return value is still an http.ClientRequest. The callback still receives an http.IncomingMessage. The response body still needs to be consumed. The only lifecycle change is that your code receives a request stream that has already been ended.

Use http.get() for GET requests with no upload body. Use http.request() when you need another method, a body, custom write timing, Expect: 100-continue, or a wrapper that controls finalization itself.

HTTP also allows 1xx informational responses before the final response. Node exposes these through request events because they arrive before the final response event.

The information event fires for 1xx responses other than 101 Switching Protocols. The event payload includes the status code, status message, headers, and raw headers.

js
const req = http.request(url);

req.on("information", info => {
  console.log(info.statusCode, info.headers);
});

req.on("response", res => res.resume());
req.end();

That event helps with 103 Early Hints, custom 1xx signals, and upstream debugging. It is separate from the final response. Code that assumes the first status code is the final status code can miss this path.

100 Continue has its own event because it affects upload timing. A client can send headers with Expect: 100-continue, wait for the server to accept the body, then stream the upload.

js
const req = http.request(url, {
  method: "POST",
  headers: { expect: "100-continue" }
});

req.on("continue", () => req.end(body));
req.on("response", res => res.resume());

The continue event means the server sent 100 Continue. At that point, the client can send the request body. The final response still arrives later through response.

This pattern needs a timer in production. A server may never send 100 Continue. Your client has to decide whether to send the body anyway, fail the request, or close the socket. Deadline and cancellation policy belongs in the client wrapper, but the event behavior starts here.

Uploads Are Request Streams

An outbound request body is an upload stream. In core HTTP, the ClientRequest itself is the writable stream.

js
import { createReadStream } from "node:fs";

const req = http.request(url, { method: "PUT" }, res => {
  res.resume();
});

createReadStream("events.ndjson").pipe(req);

The file stream writes chunks into the request. pipe() ends the destination by default when the file ends, so the HTTP request body ends too. If you write chunks manually, you must call req.end() yourself.

Backpressure follows the writable stream contract. req.write(chunk) returns a boolean. false means the request stream buffer crossed its high-water mark, and the producer should wait for drain before writing more. That pressure includes buffering inside Node and the socket write path below it.

The HTTP body framing depends on whether Node knows the size of the body.

If you set Content-Length, the peer expects exactly that many body bytes. Too few bytes can leave the peer waiting or mark the message incomplete. Too many bytes violate the declared message framing. Core HTTP validates some strict-length cases, and peers may reject or reset mismatched requests.

If you omit Content-Length for a request body, Node can use chunked transfer coding for HTTP/1.1. Each body chunk is sent with chunk framing, and the final zero-length chunk marks the end. Unknown-length upload streams fit naturally into chunked encoding.

Here is a fixed-length JSON upload -

js
const body = JSON.stringify({ name: "Ada" });

const req = http.request(url, {
  method: "POST",
  headers: { "content-length": Buffer.byteLength(body) }
});

req.end(body);

This declares the body length before sending it. Node can send the headers and body as one bounded message. Fixed-length bodies are useful for peers that reject chunked uploads, signature schemes that include the body length, and APIs that require strict framing.

Upload errors can happen before any response exists. DNS can fail before a socket is created. A connection can reset during upload. A server can reject headers and close before reading the body. A local file stream can error while the request is still open.

Many of those failures surface on the request object -

js
const req = http.request(url, res => res.resume());

req.on("error", err => {
  console.error(err.code ?? err.message);
});

req.end("payload");

The body source still needs its own error handling. If a file stream errors, your code should destroy the request or use pipeline() so failure propagation is handled for you. A request left open after its body source failed can leave the upstream waiting for bytes that will never arrive.

ClientRequest also has cork() and uncork() through the outgoing message path. They can batch small writes before flushing. Most application code gets enough batching from normal stream buffering and direct end(body) calls. Use corking only after measuring a hot path with many small synchronous writes.

Uploads become harder once authentication, signatures, multipart bodies, retries, and deadlines enter the picture. At the HTTP client layer, the core rule stays small - body bytes enter through a writable stream, and ending that stream ends the HTTP request body.

Two request-side finish states are easy to mix up. req.writableEnded means your code has ended the writable side. req.writableFinished means the stream has flushed its data through the writable machinery. A request can be ended from JavaScript while bytes are still moving toward the socket.

The finish event belongs to the outgoing request stream. It says Node has finished handling the request body on the writable side. It does not say anything about the response status.

js
req.end(body, () => {
  console.log(req.writableEnded);
});

req.on("finish", () => console.log("upload flushed"));

The callback passed to end() runs when the data has been flushed through the stream interface. The response may still be seconds away. For a large upload to a slow server, request completion and response completion are separate timelines.

Upload backpressure crosses more than one object. A file stream may pause because ClientRequest returned false. ClientRequest may buffer because the socket is still connecting. The socket may look writable from JavaScript while the kernel send buffer is applying its own flow control. Node stream backpressure gives your code a useful local signal. It is not a full network-speed guarantee.

stream.pipeline() is often better than manual piping when the upload source can fail -

js
import { pipeline } from "node:stream/promises";

const req = http.request(url, { method: "PUT" }, res => {
  res.resume();
});

await pipeline(createReadStream("events.ndjson"), req);

The pipeline promise covers the upload path. You still need to handle the response path. A successful upload pipeline only means the request body reached the writable destination. The upstream can still return a 500, close during response body transfer, or send a malformed response.

Response Bodies Decide Reuse

A client bug often starts after the status code has already arrived.

js
http.get(url, res => {
  if (res.statusCode !== 200) return;
  res.pipe(process.stdout);
});

The error branch returns without reading or destroying the response body. That response still owns the socket until Node can handle the unread bytes. On a keep-alive connection, the next response can only be parsed safely after the current response body has reached its end or the connection has been closed.

Response body handling means your code does one of three things - read the full body, drain it deliberately, or destroy it and accept that the connection may not be reused. In core HTTP, draining can be as small as res.resume() when you do not care about the bytes.

js
http.get(url, res => {
  if (res.statusCode !== 200) {
    res.resume();
    return;
  }

  res.pipe(process.stdout);
});

Now both branches handle the body. The success branch pipes it somewhere. The failure branch discards it. Once the parser reaches the end of the body, the agent can decide whether the socket is reusable.

Destroying the response has a different cost -

js
http.get(url, res => {
  if (res.statusCode > 299) {
    res.destroy();
    return;
  }

  res.pipe(process.stdout);
});

res.destroy() tears down the response stream. It can also close the underlying socket. That is fine when stopping the transfer is more valuable than keeping the connection. It is wasteful when the body was tiny and the socket could have been reused.

The status code does not decide reuse. The body ending does. A 404 response with a short, fully drained JSON body can leave the connection reusable. A 200 response with a large unread body can pin or close the connection. Client code that checks only the status and forgets the body is a common source of pool trouble.

Response errors also depend on timing. A parser error before final headers usually reaches the request as an error. A socket reset after final headers can surface on the response stream. A decompression error in a wrapper stream can surface on that wrapper. Code that only listens to req.on("error") catches setup failures and misses later body failures.

js
http.get(url, res => {
  res.on("error", err => console.error(err.message));
  res.pipe(process.stdout);
}).on("error", err => {
  console.error(err.message);
});

That covers request setup failures and response stream failures. Real wrappers usually turn both into one promise or one result object, but the underlying events remain separate.

The same body rule applies to fetch(), but the body is a Web stream. The promise returned by fetch() resolves when headers are ready. Body handling happens after that.

js
const res = await fetch(url);

if (!res.ok) {
  await res.body?.cancel();
  throw new Error(`upstream returned ${res.status}`);
}

console.log(await res.text());

The body is one-shot. res.text(), res.json(), res.arrayBuffer(), and stream reads all consume the same body. Once one reader consumes it, later readers fail.

Node also gives you adapters between Web streams and Node streams -

js
import { Readable } from "node:stream";

const res = await fetch(url);
const body = Readable.fromWeb(res.body);

for await (const chunk of body) {
  process.stdout.write(chunk);
}

The adapter changes the stream interface your code sees. It does not create a second body. The bytes still flow once from the socket parser through the response body.

Undici's lower-level request() path returns a body that is a Node readable stream with Body mixin methods. The same ownership rule applies there too - consume it, dump it, or destroy it.

js
const { body, statusCode } = await client.request({
  path: "/users",
  method: "GET"
});

if (statusCode !== 200) await body.dump();
else console.log(await body.text());

body.dump() is an Undici helper that reads and discards response bytes up to its configured limit. It exists because client pools need response bodies to finish. An unread body can hold a connection, and a held connection can block later work behind pool limits.

Garbage collection is the wrong cleanup plan in a Node service. A busy server may not get quiet windows where unread response bodies are collected quickly. Leaving body release to GC can inflate connection counts, stall pools, or hide upstream pressure. Make the body decision in code.

There is another body rule - decoding helpers buffer by design. await res.text() collects the whole decoded response text before it resolves. await res.json() collects the body and parses it. await res.arrayBuffer() collects bytes. These helpers are fine for bounded API responses. They are the wrong tool for unbounded logs, large exports, and upstreams without strong size guarantees.

Streaming keeps memory tied to chunk flow -

js
const res = await fetch(url);

for await (const chunk of res.body) {
  await sink.write(chunk);
}

A Web stream async iterator reads chunks until the body ends. After the loop, the body is consumed, and the dispatcher can release the connection according to its reuse rules.

Client helper names should make size policy obvious. getJson() can read the whole body. downloadToFile() should stream. A helper that only checks headers should cancel or dump the body. Boring names prevent resource bugs.

fetch() Maps To The Same HTTP Work

fetch() gives you the web-compatible API. In Node, Undici is the HTTP client engine underneath the implementation. The call looks smaller than core HTTP, but the runtime still has to do the same work - normalize the request, acquire a connection, write headers and body bytes, parse response headers, expose a streaming body, and release or close the connection after the body is handled.

js
const res = await fetch("http://localhost:3000/users", {
  headers: { accept: "application/json" }
});

const users = await res.json();
console.log(users.length);

The promise resolves with a Response. HTTP error status codes such as 404 and 500 still fulfill the promise. Network failures, invalid request construction, aborted requests, parser failures, and lower-level connection failures reject the promise, often through a TypeError with a lower-level cause attached by the implementation.

That status behavior comes from the web API. In backend clients, it can surprise people. If you forget the res.ok check, a JSON error response can move through normal data flow, or .json() can throw later when an HTML error page arrives.

js
const res = await fetch(url);

if (!res.ok) {
  await res.body?.cancel();
  throw new Error(`bad upstream status ${res.status}`);
}

return res.json();

The optional chain handles cases where fetch exposes a null body, such as 304 and HEAD responses. The cancellation line is about resource ownership. The error branch has chosen to ignore the body, so it still needs to release it when a body exists.

Backend fetch() also has server-side behavior that differs from browser fetch. Node does not run browser CORS enforcement for ordinary server-side requests. Node also does not provide a browser cookie jar for global fetch. Redirects, headers, body streaming, and connection reuse are handled by Node and Undici inside your process, not by a browser networking stack with page state.

Streaming request bodies have one Node-specific edge. Undici accepts async iterable bodies for fetch, and streaming request bodies use duplex: "half" in the fetch init object.

js
await fetch(url, {
  method: "POST",
  body: source,
  duplex: "half"
});

source can be an async iterable that yields chunks. The duplex value is part of the Node and Undici contract for streamed uploads. It does not mean your application gets HTTP/2-style multiplexing. It is a fetch construction option that allows a streamed request body.

Core http.request() exposes the writable request stream directly. fetch() accepts a body object and lets Undici drive the write path. Both write bytes to a socket through a client engine.

Fetch can also fail earlier during request construction. new Request() validates the method, URL, headers, body state, and body compatibility before dispatch. A consumed body cannot be sent again. A malformed URL fails before DNS. A forbidden body and method combination fails before a socket exists. With core HTTP, some invalid states appear as request errors later. With fetch, many construction failures reject the returned promise or throw while building the Request.

js
const request = new Request(url, {
  method: "POST",
  body: JSON.stringify({ ok: true })
});

await fetch(request);

The Request object owns a one-shot body too. Passing the same request object into two fetch calls after the first call consumes its body fails. Clone before consumption if the body source supports it. For streamed bodies, assume one pass.

Headers also have fetch-specific behavior. The Headers object lowercases names for matching, combines values according to its rules, and presents a web-compatible API. Core http.request() accepts plain objects, arrays in some paths, and lower-level header mutation methods. When code moves between core HTTP and fetch, check duplicate headers, casing assumptions, and any need for raw header access.

Fetch sets several defaults at the client layer. It can add Accept, Accept-Language, and Accept-Encoding when your code has not supplied them. The exact defaults depend on the implementation and version. If your service needs a stable value, set the header yourself.

Timeouts also feel different. Core HTTP exposes request and socket timeout methods. Fetch commonly uses signals for cancellation and deadlines. The local rule is simple - a fetch deadline should be explicit at the call site, and the response body still needs a release path if the request reaches headers and your code then rejects for policy reasons.

The client APIs line up like this -

Client pathPublic APIBody handlingPool owner
http.request()ClientRequest events and streamsIncomingMessage Node streamhttp.Agent
http.get()ended GET convenience requestIncomingMessage Node streamhttp.Agent
global fetch()web-compatible Promise<Response>Web stream and Body mixinUndici dispatcher
Undici request()dispatcher API resultNode stream plus Body mixinUndici dispatcher

The API surface changes. The transport work stays the same.

Redirects And Decoded Bodies

Redirects change which request produced the response your code receives.

Fetch follows redirects by default. The final Response is the result after the redirect chain completes, within the implementation's redirect limit. res.url points at the final URL. Intermediate responses are handled by the fetch algorithm instead of being handed to your code as separate Response objects.

js
const res = await fetch("http://localhost:3000/old-path");

console.log(res.status);
console.log(res.url);

With normal follow mode, a 301, 302, 303, 307, or 308 can result in a final 200 from a different URL. That is convenient for many clients. It can also hide what happened during debugging.

Fetch also supports redirect modes -

js
const res = await fetch(url, { redirect: "manual" });

console.log(res.status);
console.log(res.headers.get("location"));

Manual mode exposes the redirect response to your code. Error mode rejects when a redirect is encountered. Follow mode walks the redirect chain.

Method and body behavior depends on the redirect status. For example, 303 See Other switches many flows to a GET for the next request. 307 and 308 preserve method and body semantics. The fetch implementation follows the web fetch rules. If your service signs requests, counts writes, or sends non-replayable bodies, redirect handling should be part of the client design.

Redirects also create header risk. A request that follows from one origin to another may need different credential handling, signature scope, or tracing metadata. Browser fetch has browser-specific credential rules. Node backend code has process-owned headers and no browser cookie jar. Treat cross-origin redirects as a policy decision in your wrapper, even when the low-level client can follow them.

Core HTTP leaves redirect policy to you. You receive the redirect response like any other response. Your code reads the Location header, decides whether to issue another request, and owns body replay behavior.

Content encoding changes the bytes your code receives.

An upstream can send Content-Encoding with values such as gzip, deflate, or br. That says the message body is encoded. Node's fetch path advertises supported encodings, and Undici decodes common response encodings before body readers hand data to your code. So await res.text() usually sees decoded text, not compressed bytes.

That is helpful for application code. It can confuse debugging.

Headers may still describe the encoded HTTP message. Content-Encoding can still be present. Content-Length, when present, can describe the encoded length from the wire instead of the decoded byte count your code reads. If you compare Content-Length to Buffer.byteLength(await res.text()), you may be comparing two different byte sequences.

Multiple encodings can appear as a list. A response can be encoded more than once. Undici limits encoding layers to protect the process from resource exhaustion. Your application should still apply decoded-size limits when it reads whole bodies. Compression can turn a small wire body into a much larger decoded body.

Core HTTP keeps encoded bytes visible because it stops at HTTP framing. The response stream yields the message body after transfer framing, with content encoding still applied. Fetch and undici.fetch() apply supported content-encoding decoding before Body readers hand data to your code. Lower-level undici.request() exposes a readable body with Body mixin readers, and decoding behavior depends on the client layer or wrapper you use.

That explains a common mismatch. Core HTTP and fetch can read different byte sequences from the same upstream response unless you align Accept-Encoding and decoding policy.

Core http.request() does not automatically gunzip the body. If you want decoded bytes on that path, use node:zlib based on the Content-Encoding header.

js
import { createGunzip } from "node:zlib";

http.get(url, res => {
  const body = res.headers["content-encoding"] === "gzip"
    ? res.pipe(createGunzip())
    : res;

  body.pipe(process.stdout);
});

That example handles only gzip. Real clients also handle more encodings, invalid encodings, size limits, and decompression failures. Keep those checks near the client wrapper because decoded size can be much larger than encoded size.

Automatic decompression also affects signatures and hashes. If you need the exact encoded wire body for verification, fetch body helpers are usually the wrong layer. Use a lower-level path where you control Accept-Encoding and decoding.

The same caution applies to logs. Logging decoded body size, encoded content length, and final URL as separate fields saves time during upstream debugging.

Undici Dispatchers

Undici puts a Dispatcher under its high-level APIs. A dispatcher receives a request description and schedules it onto a client, pool, or other transport strategy. fetch(), undici.request(), undici.stream(), and undici.pipeline() all eventually need a dispatcher to do the outbound work.

At the low level, Dispatcher.dispatch(options, handler) receives request options and callbacks. Higher-level methods such as request() and stream() are built on top of that API. Application code usually uses the higher-level methods, but the dispatcher model explains why Undici configuration feels different from core http.Agent configuration.

Core HTTP centers pooling around http.Agent. Undici centers it around dispatcher implementations.

text
Dispatcher
  Client -> one origin, one connection
  Pool -> one origin, many clients
  BalancedPool -> several upstream pools
  Agent -> many origins

The next examples import from the npm undici package. Add that dependency in projects that run them.

An Undici Client targets one origin - protocol, hostname, and port. For HTTP/1.1, it maps to one connection at a time. It can keep that connection alive, dispatch requests through it, and optionally pipeline multiple requests on it.

js
import { Client } from "undici";

const client = new Client("http://localhost:3000");
const { body } = await client.request({ path: "/", method: "GET" });

console.log(await body.text());
await client.close();

The URL passed to Client is the origin. The request path is supplied per request. That keeps origin ownership separate from request-target ownership.

An Undici Pool targets one origin and owns multiple Client instances for that origin. A pool spreads concurrent work over clients according to its configuration. The connections option controls how many clients the pool can create. With HTTP/1.1, that roughly means how many sockets can carry concurrent exchanges for that origin, adjusted by pipelining and connection state.

js
import { Pool } from "undici";

const pool = new Pool("http://localhost:3000", {
  connections: 4
});

const { body } = await pool.request({ path: "/users", method: "GET" });
await body.dump();

A BalancedPool owns pools for multiple upstream origins. It is useful when you have several equivalent upstream addresses and want the dispatcher to choose between them. Service discovery and load-balancing policy can build on top of that.

An Undici Agent handles many origins. It creates or reuses a per-origin dispatcher, usually a Pool, as requests arrive. It is the nearest Undici concept to core http.globalAgent, although the implementation and API surface are different.

js
import { Agent, request } from "undici";

const dispatcher = new Agent({ connections: 8 });
const { body } = await request(url, { dispatcher });

await body.dump();
await dispatcher.close();

The per-request dispatcher option gives one operation a specific dispatcher. That works well for a client wrapper, a test using a mock dispatcher, or one upstream with custom pool settings.

Choose the dispatcher outside the hot request function when possible. Creating a new Agent or Pool per application request defeats connection reuse. The dispatcher needs to live long enough to keep sockets warm, reuse DNS and connection setup, and apply queue limits across related work.

js
const pool = new Pool("http://api.local", {
  connections: 8
});

export function getUsers() {
  return pool.request({ path: "/users", method: "GET" });
}

That module-level pool is process state. Close it during application shutdown. In tests, close it after the test. Otherwise sockets can keep the process open.

close() and destroy() have different meanings. close() stops accepting new work and waits for queued or running work to finish where the dispatcher supports graceful shutdown. destroy() tears down the dispatcher and errors pending work. Use close() during normal shutdown. Use destroy() when pending work should stop immediately.

js
try {
  const { body } = await pool.request({ path: "/", method: "GET" });
  await body.dump();
} finally {
  await pool.close();
}

That pattern is good for scripts. A long-running service would close the shared pool during process shutdown instead of after every call.

The global dispatcher is process-level state used by Undici APIs when no per-request dispatcher is supplied. Node's built-in fetch path is also backed by Undici, and dispatcher behavior can vary with the Undici version bundled into Node and the Undici package version installed in your project. Treat global dispatcher changes as bootstrap configuration, not as random helper logic.

js
import { Agent, setGlobalDispatcher } from "undici";

setGlobalDispatcher(new Agent({
  connections: 16
}));

Every later Undici call that reads the global dispatcher can now use that agent. That is convenient in a service with one startup file. It is painful in tests or libraries if the change happens at import time.

Prefer per-request dispatchers inside reusable packages. Use setGlobalDispatcher() in application bootstrap where process-wide HTTP client policy belongs.

Here is what happens during dispatch. For fetch(url), the request becomes an internal request record. The URL decides the origin. The request asks its dispatcher to dispatch. If the dispatcher is an Agent, it finds or creates the pool for that origin. If it is a Pool, it chooses or creates a client for that origin. If it is a Client, it schedules the request on that client's connection.

The selected client owns the socket work. It opens a connection if needed, writes request headers, streams or writes the body, runs the HTTP parser for the response, and calls response handlers as headers and body chunks arrive. The public Response or Undici response object is built above those callbacks.

Pool pressure appears before JavaScript sees a response. A saturated dispatcher can queue work. A busy pipelined connection can make later responses wait behind earlier ones. A dead socket can fail a dispatch and force cleanup. A response body that user code never consumes can keep a client occupied and reduce usable pool capacity.

Undici's lower APIs expose that pressure through return values, events, stats objects, and pending promises depending on the API. dispatch() returns a boolean that tells a low-level caller whether more dispatch calls can make progress before a drain event. Pool exposes stats for pool-level inspection. Higher-level promise APIs hide some of that, but the scheduling state still exists underneath.

The handler path also uses a different JavaScript surface from core HTTP events. A low-level dispatcher handler receives response-start, response-data, response-end, and response-error callbacks. The higher-level request() method wraps that into { statusCode, headers, body, trailers }. Fetch wraps it into Response.

text
dispatch handler -> callbacks
request() -> response data object
fetch() -> Response object
stream() -> writable factory
pipeline() -> Duplex

Choosing the API is partly about performance and partly about readability. fetch() is portable and familiar. request() is direct and exposes Undici's body helpers. stream() writes response bytes into a provided writable without creating a user-facing readable for the body. pipeline() creates a duplex for request and response stream composition. Pick the smallest API that still expresses your body handling clearly.

The dispatcher is more than a bag of options. It is where outbound HTTP work waits, where a connection is chosen, and where reuse rules apply after the body ends.

That model also explains mock clients. Undici's mock dispatcher APIs fit into the same dispatch slot. A test can replace network dispatch with programmed responses because the high-level client code only needs a dispatcher-compatible object.

There is one version detail to keep visible. When code imports an installed undici package, that package has its own version. When code uses global fetch, it uses the Undici version bundled into Node. process.versions.undici tells you the bundled version.

js
console.log(process.version);
console.log(process.versions.undici);

Record both values when debugging fetch behavior. A Node upgrade can change the bundled Undici version, which can change redirect, parser, stream, timeout, proxy, or dispatcher behavior.

Global fetch comes from Node. Imports such as import { Pool } from "undici" come from your dependency tree. A newer installed package can give your application newer dispatcher APIs, while global fetch still uses the bundled Undici version.

Keep the path visible. If a bug report says "Undici failed", write down whether the path was global fetch, imported undici.fetch, imported undici.request, or core http.request(). Those paths can have different versions, options, and body types.

Pipelining Is Ordered Concurrency

Undici pipelining is an HTTP/1.1 feature exposed through the client dispatcher model. It lets a client send more than one request on the same HTTP/1.1 connection before the earlier responses have completed.

js
const client = new Client("http://localhost:3000", {
  pipelining: 4
});

That setting allows up to four in-flight requests on the same client connection, subject to method, body, and dispatcher rules.

HTTP/1.1 pipelined responses must come back in request order. If request A is sent before request B on the same connection, response A must complete before response B can be delivered on that connection. That creates the head-of-line behavior from the previous subchapter, now applied to pipelined client work.

That ordering rule is the main trade. Pipelining can put several requests onto the wire quickly. The parser still has to deliver the first response before the second response on that connection. A slow first response can delay later responses even if the server already finished their application work.

js
const one = client.request({ path: "/slow", method: "GET" });
const two = client.request({ path: "/fast", method: "GET" });

const [a, b] = await Promise.all([one, two]);
await a.body.dump();
await b.body.dump();

With pipelining enabled on one connection, the /fast response can wait behind /slow because HTTP/1.1 response delivery follows request order. With a wider pool, those requests may land on different connections and avoid that specific delay.

Pipelining can reduce connection count and avoid waiting for a full request and response round trip before sending the next request. It can also make latency worse when one slow response sits ahead of fast responses on the same connection.

Undici is conservative around request safety. Non-idempotent requests and streamed request bodies have stricter dispatch behavior because replay and failure handling get tricky. The narrow rule for this chapter is enough - pipelining changes when requests are sent, but responses on one HTTP/1.1 connection are still delivered in order.

The blocking and idempotency flags in Undici's lower request options exist because the client needs scheduling hints. A request expected to hold a response open can block later pipelined work. A request with a streamed body has different failure behavior from a small GET. Those hints help Undici decide when pipelining is safe for a specific request.

Treat pipelining as an origin-specific setting. A metrics endpoint with small, fast, idempotent responses may behave well. A reporting endpoint with large, uneven responses can perform worse. One third-party API may tolerate pipelining cleanly. Another may have intermediaries that handle it poorly.

HTTP/2 multiplexing uses a different protocol mechanism. HTTP/2 has independent streams inside one connection. HTTP/1.1 pipelining has ordered responses on one connection. If an HTTP/1.1 response at the front is slow, later pipelined responses wait behind it.

Pipelining belongs near measured, controlled clients - same origin, known server behavior, bounded response times, and bodies that are consumed promptly. It is a poor default for random third-party APIs with uneven latency or large responses.

For many HTTP/1.1 clients, the safer scaling knob is pool width - more connections in a Pool or Agent. Pipelining is a second knob for specific workloads. Both knobs still require body consumption. Neither helps a client that leaves response bodies unread.

Pool width and pipelining interact. Four connections with pipelining 1 gives four concurrent HTTP/1.1 exchanges. One connection with pipelining 4 can send four requests quickly, but ordered responses still share one connection. Four connections with pipelining 4 can put more work in flight, and it can also amplify head-of-line delays and upstream pressure. The right setting comes from measured latency distribution, response size, server behavior, and connection budget.

Start with the boring setup - fixed pool width, prompt body consumption, and no pipelining. Add pipelining only when the upstream and workload prove that ordered concurrency helps.

Choosing The Client Layer

Use core http.request() when you need Node stream control, exact event timing, manual redirect policy, raw response bytes, or integration with older code that already uses http.Agent.

Use global fetch() when you want web-compatible Request and Response objects, simple request construction, Body mixin readers, and the default Undici-backed client path built into Node.

Use installed Undici APIs when you need explicit dispatcher objects, pool control, lower-level request() or stream() methods, mock dispatchers, pipelining, or a newer Undici feature than the one bundled with your current Node release.

These paths can coexist in one service, but each path should own its pooling and body rules deliberately. Mixing core agents, global fetch, and custom Undici dispatchers without a clear owner makes outbound traffic hard to reason about.

A clean pattern is to wrap each upstream behind a small client module. That module picks one transport layer, owns dispatcher or agent configuration, consumes bodies on error paths, and returns application-level results to the rest of the service.

js
export async function getJson(url, { fetchImpl = fetch } = {}) {
  const res = await fetchImpl(url);

  if (!res.ok) {
    await res.body?.cancel();
    throw new Error(`GET failed with status ${res.status}`);
  }

  return res.json();
}

The wrapper keeps the fetch dependency visible. It also makes the body rule easy to review. Every branch either consumes useful bytes or cancels bytes the caller has chosen to ignore.

A larger wrapper usually owns four decisions.

It owns destination construction. The caller passes domain data, and the wrapper builds the URL, path, query string, and headers. That keeps request-target bugs out of random call sites.

It owns connection policy. With core HTTP, that means an http.Agent selected at module or application startup. With Undici, that means a dispatcher selected at module or application startup. The wrapper should avoid creating pools per request because pool lifetime is what makes reuse useful.

It owns response policy. A 404 from one upstream may be valid data. A 404 from another may be a dependency failure. The wrapper translates status codes and headers into the application's result structure, while still draining or canceling bodies on every branch.

It owns byte policy. Small JSON responses can use .json(). Large downloads should stream. Compressed responses need decoded-size limits. Error bodies may need small bounded reads for diagnostics followed by drain or cancel. The wrapper is the right place for those limits because it knows the upstream contract.

js
export async function readErrorBody(res, limit = 4096) {
  const text = await res.text();
  return text.length > limit ? text.slice(0, limit) : text;
}

That helper is intentionally small, and it has a size problem. res.text() buffers the whole body before slicing. It is fine only for an upstream that already has a response-size cap. For an untrusted or unbounded response, use a streaming bounded reader.

Core HTTP needs the same policy, only with Node streams -

js
function discard(res) {
  res.resume();
  res.on("error", () => {});
}

That helper drains and ignores response stream errors. A production wrapper may log the error, increment a metric, or destroy the response based on status and size. The main point is ownership. The body path should be written down in code, not left to caller memory.

Client modules also make shutdown cleaner. A shared Undici pool or agent can expose a close() function from the module. The application shutdown path calls it after it stops accepting new work. Core http.Agent has destroy() for closing sockets. Undici dispatchers have close() and destroy(). The wrapper can hide those API details from the rest of the service.

js
const dispatcher = new Agent({ connections: 8 });

export function closeHttpClient() {
  return dispatcher.close();
}

Tests benefit too. A wrapper that accepts fetchImpl or dispatcher at construction time can use a mock without changing application code. A wrapper that imports global fetch directly in every helper is harder to isolate and easier to break with global dispatcher changes.

Migration code needs extra care. Moving from core HTTP to fetch changes response body type, redirect behavior, decompression behavior, and error behavior all at once. Moving from fetch to imported Undici APIs changes body helpers and dispatcher ownership. Keep migrations narrow. Swap one upstream client at a time, record before-and-after headers and body sizes in a lower environment, and watch connection counts during load tests. A change that only touches a few call sites can still change how many sockets the process opens.

The reverse migration has its own traps. Replacing fetch with http.request() gives you rawer stream control, but it removes automatic redirect handling and decoded body helpers. Any code that expected res.ok, res.url, or one-shot Body mixins needs a local replacement. Core HTTP gives you status, headers, and a stream. The wrapper must rebuild the higher-level contract deliberately.

The last design choice is observability. Log the final URL only when it is safe to log. Record method, origin, status, redirect count when available, decoded byte count, elapsed time, and whether a socket was reused when the chosen API exposes that data. Avoid logging full headers and bodies by default. Cookies, authorization headers, and signed URLs can leak easily, and the client wrapper is one of the first places that can happen.

That is the outbound client story in Node. The API can be http.request(), http.get(), global fetch(), or an Undici dispatcher call. The runtime still has to acquire a connection, serialize a request, parse a response, stream the body, and release the connection after the body reaches a clear end.