HTTP Clients, fetch, and Undici
http.request() is the lower-level way to make an outbound HTTP request in Node. You reach for it when you want to drive the request stream yourself rather than hand that work to a convenience wrapper. The call is short, and Node does a fair amount of work behind it.
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 fires once Node has received and parsed the response headers, not at the moment you create the request object. Node hands it an http.IncomingMessage, which is the response.
res.resume() drains the response body. A response body that nobody reads keeps the socket tied to that request, and a socket that cannot be reused leaves your pool with fewer connections for later requests.
req.end() finishes the outbound request stream. For a request with no body, it tells Node that no more request bytes are coming. Node might prepare headers and assign a socket before this point, but from your code's point of view the request stream stays open until you end it.
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 an outbound request needs. That includes 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.
URL/options -> ClientRequest
ClientRequest -> http.Agent
Agent -> DNS and socket acquisition
socket -> HTTP request bytes
parser -> IncomingMessage
IncomingMessage -> body consumerThe agent handles socket acquisition. A reusable socket for the destination gets used directly. If every matching socket is busy and the agent can open another connection, it does. Once the pool is at its limit, the request waits in the agent's pending queue.
The request object can exist before DNS has finished and before any TCP connection or request bytes exist. During that window your code can still set headers, write body chunks into the request stream, and attach listeners.
Node normalizes options early. It parses a string URL, reads a URL object for protocol, hostname, port, username, password, pathname, and search, and lets an options object override parts of that URL. From there Node builds the request path from pathname plus search, picks a default method when none is given, prepares headers, and selects the agent. The Host header comes from the destination unless your code sets one.
A lot of client bugs start in this setup step. The URL path belongs in the HTTP request target, and the hostname belongs in connection acquisition. A proxy request may put an absolute URL in the request line, but ordinary origin requests carry only the path and query. Mix those values by hand and the TCP connection can go to one host while the HTTP message names another.
Here is a normal GET request built from a URL, with options overriding the method and headers.
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. Building this object does not open a socket yet.
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, which a body write or req.end() can trigger. req.flushHeaders() covers the less common case where headers should go out before the body is ready.
Here is a POST where Node can use chunked transfer coding, because the final body size was never declared.
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.
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 once status and headers are ready. The body may still be arriving, and you read those chunks from the response stream as they come.
Request errors land on the request object. That covers DNS failures, refused connections, resets before a valid response, parser errors, request-stream failures, and timeouts. A response can also start cleanly and then fail partway through the body, so your code needs error handling on the request side and the response side.
The client request moves through a chain of states.
created -> assigned socket -> headers sent
body writing -> request ended
response headers parsed -> body read
complete -> socket reused or closedTiming changes from one request to the next. A reused socket skips connection setup. A POST can spend time uploading before the final response arrives, and a server might send 100 Continue first. A stale pooled socket can fail the moment Node writes the first byte.
http.request() exposes all of these states, which is why you reach for it. You get the real stream and event model, and you control every detail.
Several pieces of state move together behind the JavaScript object. The ClientRequest holds outgoing headers and body state, the agent holds pool state, the socket holds transport state, and the HTTP parser holds 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, and the request waits while that finishes. A DNS failure means no HTTP bytes ever go out. A failed TCP connection means no HTTP response comes back. When the socket connects and then resets during the first write, the request already exists but response parsing has not started.
Once response bytes arrive, llhttp, the C parser Node uses for HTTP, reads the status line and headers. Node will not emit the final response event until it has parsed a complete final response header section. After that event, the parser keeps feeding body bytes into the IncomingMessage stream until the HTTP framing rules mark the body finished.
Connection reuse depends on that body reaching a valid end. A close-delimited body forces the connection to close so the end is marked. A body with a correct Content-Length or a chunked terminator lets the parser find the end while the connection stays open, and then the agent can return the socket to the free pool.
Reading the response body all the way to its end is part of getting an HTTP client right, because that is what frees the connection for reuse.
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.
http.get("http://localhost:3000/health", res => {
console.log(res.statusCode);
res.resume();
});You still get back an http.ClientRequest, the callback still receives an http.IncomingMessage, and you still have to consume the response body. The one change is that the request stream arrives already ended.
Reach for http.get() on GET requests with no upload body. For anything else, another method, a body, custom write timing, Expect: 100-continue, or a wrapper that controls its own finalization, use http.request().
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.
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.
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.
In production this pattern needs a timer. A server may never send 100 Continue, and then your client has to decide whether to send the body anyway, fail the request, or close the socket. That deadline and cancellation policy belongs in the client wrapper. The raw event is what you get here.
Uploads And Request Streams
An outbound request body is an upload, and in core HTTP the ClientRequest itself is the writable stream you write it to.
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, the stream's way of telling you its buffer is full, follows the writable stream contract here. req.write(chunk) returns a boolean. A false means the request stream buffer crossed its high-water mark, so the producer should wait for drain before writing more. That pressure covers buffering inside Node and the socket write path beneath it.
The HTTP body framing depends on whether Node knows the size of the body.
Set Content-Length and the peer expects exactly that many body bytes. Fewer than that can leave it waiting or mark the message incomplete, and more than that breaks the declared framing. Core HTTP validates some strict-length cases, and peers may reject or reset a mismatched request.
Omit Content-Length for a request body and Node can use chunked transfer coding on HTTP/1.1. Each body chunk goes out with chunk framing, and a final zero-length chunk marks the end. An upload stream of unknown length fits chunked encoding well.
Here is a fixed-length JSON upload.
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 at all. DNS can fail before a socket is even created. A connection can reset mid-upload. A server can reject your headers and close before it reads the body, and a local file stream can error while the request is still open.
Many of those failures surface on the request object.
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 get harder once authentication, signatures, multipart bodies, retries, and deadlines enter the picture. At the HTTP client layer the rule is still simple. Body bytes go in through a writable stream, and ending that stream ends the HTTP request body.
The request side has two finish states that look alike. 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.
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 pauses when ClientRequest returns false. ClientRequest itself buffers while the socket is still connecting. The socket can look writable from JavaScript even as the kernel send buffer applies its own flow control. Node stream backpressure gives your code a useful local signal, but it is not a guarantee about real network speed.
stream.pipeline() is often better than manual piping when the upload source can fail.
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 And Connection Reuse
A common mistake shows up right after the status code arrives.
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.
Handling a response body means your code does one of three things with it. Read the full body, drain it on purpose, or destroy it and accept that the connection may not come back. In core HTTP, draining can be as small as res.resume() when you do not care about the bytes.
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, and 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 carries a different cost.
http.get(url, res => {
if (res.statusCode > 299) {
res.destroy();
return;
}
res.pipe(process.stdout);
});res.destroy() tears down the response stream and can close the underlying socket with it. When stopping the transfer is worth more than keeping the connection, that is the right call. When the body was tiny and the socket could have been reused, you have thrown away a connection for nothing.
Reuse depends on the body reaching its end, not on the status code. A 404 with a short, fully drained JSON body can leave the connection reusable, while a 200 with a large unread body can pin or close it. Watching the status code while forgetting the body is a classic way to run a pool dry.
A socket only goes back to the pool after its response reaches a definite end. So on every branch, do something with the body. Read it to completion, drain it with res.resume() or body.dump(), or destroy it. The error branch that ignores the bytes counts too. If you leave the body for garbage collection, connection counts climb and pools stall, because a busy process rarely gets the idle windows GC needs to run.
Where a response error shows up depends on timing. A parser error before the final headers usually reaches the request as an error. A socket reset after the final headers tends to surface on the response stream instead, and a decompression error in a wrapper stream surfaces on that wrapper. Code that only listens to req.on("error") catches the setup failures and misses the later body failures.
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. A production wrapper usually folds both into one promise or one result object, but the underlying events stay 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.
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.
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 holds there. Consume it, dump it, or destroy it.
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.
Decoding helpers buffer by design. await res.text() gathers the whole decoded body before it resolves, await res.json() gathers it and parses it, and await res.arrayBuffer() gathers the raw bytes. These work for bounded API responses. They fall apart on unbounded logs, large exports, and any upstream without a strong size guarantee.
Streaming holds only the current chunk in memory instead of the whole body.
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.
Name client helpers so the size policy is obvious from the call site. A getJson() can read the whole body, a downloadToFile() should stream it, and a helper that only checks headers should cancel or dump the body.
fetch() And Undici
fetch() is the web-compatible API, and in Node it runs on Undici as the HTTP client engine. Writing the call takes less code than core HTTP, yet the runtime does all the same work. It normalizes the request, acquires a connection, writes headers and body bytes, parses response headers, exposes a streaming body, and releases or closes the connection once the body is handled.
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.
404 and 500 fulfill a fetch() promise the same way 200 does. The promise only rejects on network failures, aborts, invalid construction, and parser errors. So check res.ok or res.status yourself. And when the status is bad, release the body with res.body?.cancel() before you throw, since that branch chose not to read it.
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 the cases where fetch gives you a null body, like 304 and HEAD responses. That cancellation line is there for resource ownership. The error branch has decided to ignore the body, so it still has to release it when one exists.
Backend fetch() behaves differently from browser fetch in a few ways. For ordinary server-side requests Node runs no browser CORS enforcement, and global fetch has no browser cookie jar. Redirects, headers, body streaming, and connection reuse all happen inside your process through Node and Undici, not in a browser networking stack that carries 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.
await fetch(url, {
method: "POST",
body: source,
duplex: "half"
});Streaming a fetch request body, or passing an async iterable, needs duplex: "half" in the init object. Leave it out and the call throws before any socket opens. The flag turns on a streamed upload and nothing more. It does not give you HTTP/2-style multiplexing.
source here is an async iterable that yields chunks. The duplex value is part of the Node and Undici contract for streamed uploads. It enables a streamed request body and nothing else, so do not read it as a request for HTTP/2-style multiplexing.
Core http.request() hands you the writable request stream directly. fetch() takes a body object and lets Undici drive the write path. Either way the bytes reach 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 anything is dispatched. A consumed body cannot be sent again, a malformed URL fails before DNS, and a forbidden combination of body and method fails before a socket exists. Core HTTP lets some of these invalid states show up later as request errors. Fetch tends to reject the returned promise or throw while it builds the Request.
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 work differently. Core HTTP exposes request and socket timeout methods. Fetch usually leans on signals for cancellation and deadlines. Keep the fetch deadline explicit at the call site, and remember that the response body still needs a release path if the request reaches headers and your code then rejects it on policy grounds.
The client APIs line up like this.
| Client path | Public API | Body handling | Pool owner |
|---|---|---|---|
http.request() | ClientRequest events and streams | IncomingMessage Node stream | http.Agent |
http.get() | ended GET convenience request | IncomingMessage Node stream | http.Agent |
global fetch() | web-compatible Promise<Response> | Web stream and Body mixin | Undici dispatcher |
Undici request() | dispatcher API result | Node stream plus Body mixin | Undici dispatcher |
Redirects And Decoded Bodies
With redirects, the response your code reads may not come from the URL you requested.
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.
const res = await fetch("http://localhost:3000/old-path");
console.log(res.status);
console.log(res.url);In normal follow mode a 301, 302, 303, 307, or 308 can land you on a final 200 from a different URL. That saves work for many clients, though it can also hide what really happened when you are debugging.
Fetch also supports redirect modes.
const res = await fetch(url, { redirect: "manual" });
console.log(res.status);
console.log(res.headers.get("location"));Manual mode hands the redirect response straight to your code. Error mode rejects as soon as it hits a redirect. Follow mode, the default, walks the chain for you.
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, a different signature scope, or different tracing metadata. Browser fetch applies its own credential rules, while Node backend code works with process-owned headers and no cookie jar. Treat a cross-origin redirect as a policy decision in your wrapper, even when the low-level client is willing to follow it.
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 also changes the bytes you read.
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 convenient for application code, and it also makes some debugging confusing.
The headers can still describe the encoded HTTP message. Content-Encoding is often still present, and Content-Length, when it is there, can report the encoded wire length rather than the decoded byte count your code ends up reading. Compare Content-Length against Buffer.byteLength(await res.text()) and you may be comparing two different byte sequences.
On the fetch and undici.fetch() path, the body is already decompressed by the time you read it. But Content-Encoding stays in the headers, and Content-Length, when present, still reports the encoded wire length. For the exact wire bytes, say for a signature or a hash, use a lower-level path where you set Accept-Encoding and do the decoding yourself, not the buffered body helpers.
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.
The buffering helpers, res.text(), res.json(), and res.arrayBuffer(), hold the whole decoded body in memory. A small compressed response can blow up into a much larger payload once decoded. For untrusted or unbounded upstreams, enforce a decoded-size limit with a streaming bounded reader. Do not buffer first and slice after.
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.
So 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.
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. Production code also deals with other encodings, invalid encodings, size limits, and decompression failures. Keep those checks near the client wrapper, because the decoded size can run much larger than the 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 builds its pooling around http.Agent. Undici builds the same idea around dispatcher implementations instead.
Dispatcher
Client -> one origin, one connection
Pool -> one origin, many clients
BalancedPool -> several upstream pools
Agent -> many originsThe next examples import from the npm undici package. Add that dependency in projects that run them.
An Undici Client targets a single origin, meaning one protocol, hostname, and port. On HTTP/1.1 it maps to one connection at a time. It can keep that connection alive, dispatch requests through it, and optionally pipeline several requests on it.
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 you pass to Client is the origin, and the path comes in with each request. That split keeps the origin separate from the request target.
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.
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.
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.
Build a Pool or Agent once, at module or application startup. Never inside the per-request function. A dispatcher made per request kills connection reuse completely. And once it exists, it counts as process state. Call close() for a graceful stop or destroy() for an immediate one during shutdown, and in tests too, or its sockets can keep the process from exiting.
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() mean different things. close() stops taking new work and waits for queued or running work to finish, on dispatchers that support graceful shutdown. destroy() tears the dispatcher down and errors any pending work. Use the first for a normal shutdown and the second when pending work has to stop right away.
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.
import { Agent, setGlobalDispatcher } from "undici";
setGlobalDispatcher(new Agent({
connections: 16
}));From then on, every Undici call that reads the global dispatcher picks up that agent. That is handy in a service with a single startup file. It causes trouble in tests or libraries when the change lands at import time.
Inside reusable packages, prefer per-request dispatchers. Save setGlobalDispatcher() for application bootstrap, where process-wide HTTP client policy belongs.
Here is what dispatch actually does. For fetch(url), the request becomes an internal request record, and the URL decides the origin. The request then asks its dispatcher to dispatch it. An Agent finds or creates the pool for that origin. A Pool chooses or creates a client for that origin. A Client schedules the request on its 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 shows up before JavaScript ever sees a response. A saturated dispatcher queues work, and a busy pipelined connection makes later responses wait behind earlier ones. A dead socket fails its dispatch and triggers cleanup. And a response body that nobody consumes keeps a client busy, which cuts into 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.
dispatch handler -> callbacks
request() -> response data object
fetch() -> Response object
stream() -> writable factory
pipeline() -> DuplexTwo things drive the choice of API, performance and readability. fetch() is portable and familiar. request() is direct and gives you Undici's body helpers. stream() writes response bytes into a writable you provide, without creating a user-facing readable for the body. pipeline() gives you a duplex for composing request and response streams. Pick the smallest API that still expresses your body handling clearly.
The dispatcher is the layer that queues outbound HTTP work, picks a connection for it, and applies the reuse rules once 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.
Keep one version detail in view. An imported undici package carries its own version. Global fetch uses the Undici version bundled into Node, and process.versions.undici reports that bundled version.
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 itself, while an import like import { Pool } from "undici" comes from your dependency tree. The installed package can be newer and give your application newer dispatcher APIs, even as global fetch keeps using the bundled 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 On One Connection
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.
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 goes out before request B on the same connection, response A has to complete before response B can be delivered there. That is head-of-line blocking, where a slow response at the front holds up the ones behind it, 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.
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 about request safety. Non-idempotent requests and streamed request bodies get stricter dispatch behavior, because replay and failure handling are harder for them. The core point holds, though. Pipelining changes when requests go out, while responses on one HTTP/1.1 connection still arrive 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 do well with it. A reporting endpoint with large, uneven responses can do worse. One third-party API tolerates pipelining cleanly, and the next one has intermediaries that handle it badly.
HTTP/2 multiplexing solves this with a different protocol mechanism. It carries independent streams inside one connection, so a slow stream does not block the others. HTTP/1.1 pipelining only has ordered responses on one connection, and a slow response at the front makes the later ones wait.
Pipelining fits measured, controlled clients. That means a known origin, known server behavior, bounded response times, and bodies your code consumes promptly. It makes a bad default for random third-party APIs with uneven latency or large responses.
For most HTTP/1.1 clients, the safer way to scale is pool width, meaning more connections in a Pool or Agent. Pipelining is a second option for specific workloads. Either one still depends on body consumption, and neither does anything for a client that leaves response bodies unread.
Pool width and pipelining interact. Four connections with pipelining 1 give you four concurrent HTTP/1.1 exchanges. Drop to one connection with pipelining 4 and you can send four requests quickly, but the ordered responses still share that single connection. Push both to four connections and pipelining 4 and you put more work in flight, while also amplifying head-of-line delays and upstream pressure. The right setting comes out of measured latency distribution, response size, server behavior, and your connection budget.
A safe starting point is a fixed pool width, prompt body consumption, and no pipelining. Turn pipelining on once the upstream and workload show that ordered concurrency actually helps.
Choosing The Client Layer
Reach for core http.request() when you need Node stream control, exact event timing, manual redirect policy, raw response bytes, or you are working alongside older code that already uses http.Agent.
Global fetch() is the right pick when you want web-compatible Request and Response objects, simple request construction, Body mixin readers, and the default Undici-backed client path that ships with Node.
Go to the installed Undici APIs when you need explicit dispatcher objects, pool control, the lower-level request() or stream() methods, mock dispatchers, pipelining, or a newer Undici feature than the one bundled with your 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.
One approach that holds up is to wrap each upstream behind a small client module. The module picks one transport layer, owns the dispatcher or agent configuration, consumes bodies on error paths, and hands application-level results back to the rest of the service.
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 carries a few more decisions.
Destination construction is one. The caller passes domain data, and the wrapper builds the URL, path, query string, and headers from it. That keeps request-target bugs out of scattered call sites.
Connection policy is another. On core HTTP that means an http.Agent chosen at module or application startup, and on Undici it means a dispatcher chosen the same way. The wrapper should not create pools per request, since pool lifetime is what makes reuse pay off.
Then there is response policy. A 404 can be valid data from one upstream and a dependency failure from another, so the wrapper turns status codes and headers into the application's result structure, and it still drains or cancels bodies on every branch.
Last is byte policy. Small JSON responses can use .json(), large downloads should stream, and compressed responses need decoded-size limits. An error body may need a small bounded read for diagnostics, then a drain or cancel. The wrapper is the natural home for those limits, because it knows the upstream contract.
export async function readErrorBody(res, limit = 4096) {
const text = await res.text();
return text.length > limit ? text.slice(0, limit) : text;
}That helper is small on purpose, and it still has a size problem. res.text() buffers the whole body before it slices anything. It is safe only when the upstream already caps its response size. For an untrusted or unbounded response, use a streaming bounded reader instead.
Core HTTP needs the same policy, only with Node streams.
function discard(res) {
res.resume();
res.on("error", () => {});
}That helper drains the body and ignores response stream errors. A production wrapper might log the error, bump a metric, or destroy the response based on status and size. Either way the ownership is explicit. The body handling lives in code, not in whatever the caller happens to remember.
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.
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.
Keep full headers and bodies out of the client wrapper's default logs. Authorization headers, cookies, and signed URLs go straight into log storage when you dump everything. Log method, origin, status, redirect count, decoded byte count, elapsed time, and socket reuse instead, and treat the final URL as sensitive until you have confirmed it is safe to record.
Those are the outbound client paths 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.