HTTP Clients in Node.js: fetch, undici, AbortSignal.timeout, Keep-Alive, Streaming Bodies and Retries
In Node.js you call external services with the global fetch, but it is not safe without a response.ok check, AbortSignal.timeout, a body that is read or cancelled, and retries that respect idempotency. With real output: a catch that never runs on a 404, a request waiting 301 seconds, a connection lost to an unread body, a file streamed in 131 MB instead of 329 MB, retry waves without jitter, and a carrier tracking client tested with MockAgent.
Version note: The examples in this post were run with Node.js 26.13.1 (LTS), undici 8.12.4 and TypeScript 7.0.2.
Guess First
Bookstore, the online bookstore used as the shared example across this site's series, is about to call its carrier's API. Here is a first attempt; the carrier answers 404 for a tracking number it does not know:
import http from "node:http";
// The carrier answers 404 for a tracking number it does not know
const carrier = http.createServer((req, res) => {
res.writeHead(404, { "content-type": "application/json" }).end(JSON.stringify({ error: "unknown tracking number" }));
});
carrier.listen(52180, "127.0.0.1");
try {
const response = await fetch("http://127.0.0.1:52180/v1/tracking/BK9999TR");
const tracking = await response.json();
console.log("tracking:", tracking);
} catch (err) {
console.log("caught:", (err as Error).message);
} finally {
carrier.close();
}
Does it print tracking: or caught:?
tracking: { error: 'unknown tracking number' }
The catch never ran. fetch rejects only on network-level failures (no connection, no DNS answer, a cancelled request); a 404 or a 503 is still a response, and the promise resolves. The code took the carrier's error message for tracking data and carried on. In .NET, HttpClient.GetAsync behaves the same way, but there you have the GetFromJsonAsync or EnsureSuccessStatusCode() habit. With fetch you write that check yourself, every time.
In this post we make Node's built-in HTTP client, the global fetch and the undici library underneath it, safe for calling an external service: timeouts and cancellation, the connection pool, reading a body as a stream, retries, exponential backoff, jitter and idempotency. At the end we add a parcel tracking endpoint to the Bookstore API and test the client without touching the real network.
fetch, undici and Timeouts
The global fetch has needed no flag since Node 18 and has been stable since 21. The API is the one you know from the browser, but underneath it sits undici, an HTTP/1.1 client the Node team wrote from scratch; process.versions.undici tells you which version you have (8.12.4 in Node 26.13.1). The client side of node:http (http.request), whose server side we saw in post 10, is still there, but in new code fetch is shorter and the same API as in the browser.
The first gap is the timeout. fetch has no timeout option of its own, and undici's defaults are surprisingly long. Let's call a server that accepts the connection and never answers:
// A carrier that accepts the connection and never answers
const carrier = http.createServer(() => {});
carrier.listen(52187, "127.0.0.1");
const started = performance.now();
try {
await fetch("http://127.0.0.1:52187/v1/tracking/BK1001TR");
} catch (err) {
const cause = (err as Error).cause as { name: string; code: string };
console.log(`${(err as Error).message} after ${((performance.now() - started) / 1000).toFixed(1)} s: ${cause.name} ${cause.code}`);
} finally {
carrier.closeAllConnections();
fetch failed after 301.4 s: HeadersTimeoutError UND_ERR_HEADERS_TIMEOUT
Five minutes. According to undici's documentation, the time it waits for the headers (headersTimeout) and between body chunks (bodyTimeout) defaults to 300 seconds. When the carrier hangs, the request of the user ordering a book waits five minutes too, and every new request that arrives in the meantime holds a connection and memory. So give every outgoing call a time limit. AbortSignal.timeout() from the browser works here as well, and with AbortSignal.any(), which we met in post 07, you can combine your own limit with the client of your API closing its connection:
async function attempt(label: string, signal: AbortSignal): Promise<void> {
const started = performance.now();
try {
await fetch(url, { signal });
console.log(`${label}: answered`);
} catch (err) {
const e = err as Error;
console.log(`${label}: ${e.name} after ${Math.round(performance.now() - started)} ms (${e.message})`);
}
}
// 1) Our own deadline: 500 ms per attempt
await attempt("timeout", AbortSignal.timeout(500));
// 2) The client of our API left after 100 ms; the deadline is still 500 ms
const requestAborted = new AbortController();
setTimeout(() => requestAborted.abort(), 100);
await attempt("any", AbortSignal.any([requestAborted.signal, AbortSignal.timeout(500)]));
timeout: TimeoutError after 505 ms (The operation was aborted due to timeout)
any: AbortError after 102 ms (This operation was aborted)
Two different error names, and the difference matters. TimeoutError says your own limit ran out; retrying may make sense. AbortError says the caller gave up; in Bookstore that means the user closed the page, and retrying is pointless. The C# counterpart is linking HttpContext.RequestAborted with a timeout token through CancellationTokenSource.CreateLinkedTokenSource.
Connections, Keep-Alive and the undici Agent
Not every fetch opens a new TCP connection. undici keeps a connection pool per origin (scheme + host + port) and reuses connections with keep-alive. When you want to change the pool's settings, you install the undici package, create your own Agent and hand it to fetch through the dispatcher option. That option is not part of standard fetch; it is a Node-specific addition, and because TypeScript's RequestInit type does not have it either, you see a cast in the code. Keep the major version of the undici you install from npm the same as the one inside Node (both are 8.12.4 here).
// 20 sequential calls through the global dispatcher: how many TCP connections?
for (let i = 0; i < 20; i++) {
const response = await fetch(url);
await response.arrayBuffer();
}
console.log(`sequential, body read: 20 requests, ${sockets} connection(s)`);
// 20 parallel calls, at most 5 connections to this origin
sockets = 0;
const limited = new Agent({ connections: 5 });
await Promise.all(
Array.from({ length: 20 }, async () => {
const response = await fetch(url, { dispatcher: limited } as RequestInit);
await response.arrayBuffer();
}),
);
console.log(`parallel, connections: 5: 20 requests, ${sockets} connection(s)`);
// One connection, and the first response's body is never read
const single = new Agent({ connections: 1 });
const first = await fetch(url, { dispatcher: single } as RequestInit);
console.log("first:", first.status, "(body not read)");
const started = performance.now();
try {
const second = await fetch(url, { dispatcher: single, signal: AbortSignal.timeout(1_000) } as RequestInit);
console.log("second:", second.status);
} catch (err) {
console.log(`second: ${(err as Error).name} after ${Math.round(performance.now() - started)} ms`);
}
// Cancelling the first body gives the connection back
await first.body?.cancel();
const third = await fetch(url, { dispatcher: single, signal: AbortSignal.timeout(1_000) } as RequestInit);
console.log("after cancel():", third.status);
await third.body?.cancel();
await Promise.all([limited.close(), single.destroy()]);
sequential, body read: 20 requests, 2 connection(s)
parallel, connections: 5: 20 requests, 5 connection(s)
first: 200 (body not read)
second: TimeoutError after 1002 ms
after cancel(): 200
Twenty sequential requests went over two connections (in this run undici took turns between two of them); twenty parallel requests spread over five connections under the connections: 5 limit. The end is what matters: a response whose body nobody reads keeps its connection busy. In a single-connection pool the second request waited in line for a second and timed out. undici's README says this plainly: in the browser the garbage collector cleans up an unread body after a while, but in Node you cannot tell when that will happen, so connections can run out. The rule is simple: either read the body (json(), text(), arrayBuffer()) or release it with response.body?.cancel(). Throwing on an error response without touching its body is the most common form of this trap.
On the .NET side the connection pool belongs to SocketsHttpHandler, managed by IHttpClientFactory (post 02 of the External Service Integration and Resilience series). The Node counterpart is an Agent created once per process and reused for every call. A note on proxies: if your corporate network requires a proxy to reach the outside, you can use undici's ProxyAgent, or since Node 24 let NODE_USE_ENV_PROXY=1 read the HTTP_PROXY, HTTPS_PROXY and NO_PROXY variables; the documentation still marks this as "Active Development" (1.1).
Reading the Body as a Stream
Bookstore's supplier publishes a price file of about 50 MB every night. await response.text() loads the whole file into memory as one string. response.body, on the other hand, is a web ReadableStream; Readable.fromWeb turns it into a Node stream from post 09, and pipeline writes it to a file while keeping backpressure intact:
const response = await fetch("http://127.0.0.1:52183/feeds/prices.csv", { signal: AbortSignal.timeout(60_000) });
if (!response.ok || !response.body) {
throw new Error(`feed answered ${response.status}`);
}
if (mode === "text") {
// The whole body as one string in memory
await writeFile(target, await response.text());
} else {
// fetch gives a web ReadableStream; Readable.fromWeb turns it into a Node stream for pipeline (post 09)
await pipeline(Readable.fromWeb(response.body as ReadableStream<Uint8Array>), createWriteStream(target));
}
text: 50.0 MB in 691 ms, peak RSS 329 MB
stream: 50.0 MB in 658 ms, peak RSS 131 MB
The time is the same; peak RSS dropped from 329 MB to 131 MB, and an idle Node process uses 49 MB on this machine. As the file grows, so does the memory of text(), while the stream stays flat. There is a timeout here too: AbortSignal.timeout(60_000) covers reading the body, not just the headers; if it fires, the stream errors and pipeline closes the file.
Retries, Exponential Backoff and Jitter
External services return a 503 now and then, or drop a connection halfway. Trying such transient failures once or twice more is usually enough. We put the retry logic in a small function:
// Exponential backoff with "full jitter": a random wait between 0 and base * 2^(attempt-1), capped
export function backoffDelay(attempt: number, baseDelayMs: number, maxDelayMs: number, random: () => number = Math.random): number {
const ceiling = Math.min(maxDelayMs, baseDelayMs * 2 ** (attempt - 1));
return Math.round(random() * ceiling);
}
export async function withRetry<T>(operation: (attempt: number) => Promise<T>, options: RetryOptions): Promise<T> {
for (let attempt = 1; ; attempt++) {
options.signal?.throwIfAborted();
try {
return await operation(attempt);
} catch (err) {
if (attempt >= options.attempts || options.signal?.aborted || !options.isRetryable(err)) {
throw err;
}
const hinted = (err as RetryAfterHint).retryAfterMs;
const delayMs = Math.min(options.maxDelayMs, hinted ?? backoffDelay(attempt, options.baseDelayMs, options.maxDelayMs, options.random));
options.onRetry?.({ attempt, delayMs, err });
try {
await delay(delayMs, undefined, { signal: options.signal });
} catch (waitError) {
// timers/promises rejects with its own AbortError; surface the caller's reason instead
options.signal?.throwIfAborted();
throw waitError;
}
}
}
}
There are three decisions. Which errors are retried: left to the caller through isRetryable. How long to wait: the server's Retry-After header if it sent one, otherwise a ceiling that doubles with every attempt (exponential backoff). When to stop: when the attempts run out or the caller's signal is aborted. If the abort arrives during the wait, node:timers/promises throws its own AbortError; a test caught that difference, so we throw the reason the caller gave (signal.reason) instead.
Why jitter? The carrier returns 503 for 1.5 seconds, and 100 requests fail at the same moment:
no jitter : 500 requests, done at 3051 ms
per 250 ms: 200 0 100 0 0 100 0 0 0 0 0 0 100
full jitter: 481 requests, done at 5107 ms
per 250 ms: 158 88 41 49 26 19 23 15 13 14 8 3 1 10 2 4 3 1 2 0 1
Without jitter the 100 callers come back at the same instants: waves of 100 in 250 ms slots. A service trying to recover takes the same load on every wave. "Full jitter", picking the wait at random between 0 and the ceiling, spreads the same requests over time. The price shows in the output too: the last request finished later. The standard pipeline of Polly and Microsoft.Extensions.Http.Resilience turns jitter on by default; post 03 of the External Service Integration and Resilience series covered those settings on the .NET side. undici has its own RetryAgent as well (by default at most 5 retries, a wait that starts at 500 ms and doubles, and Retry-After support); here we wrote our own to see the mechanism.
Idempotency: When Can You Retry a POST?
Under the HTTP standard, RFC 9110, GET, PUT and DELETE are idempotent: sending the same request twice has the same effect as sending it once. POST is not. A timeout does not mean the request never arrived; maybe the carrier created the shipment and its answer was late. Retrying then creates a second shipment:
naive retry, no key:
carrier: created BK2001TR for order 1001 (key none)
carrier: created BK2002TR for order 1001 (key none)
client got BK2002TR
CarrierClient, one Idempotency-Key per operation:
carrier: created BK2003TR for order 1002 (key c720c577)
client: attempt 1 failed (carrier did not answer within 2000 ms), retrying in 131 ms
client got BK2003TR
shipments created at the carrier: 3
On the first attempt the carrier created the shipment but answered after 2.5 seconds; the client gave up at 2 seconds and tried again. Without a key, one order got two shipments. CarrierClient instead generates one Idempotency-Key (a random randomUUID()) per logical operation and sends the same key with every attempt; the carrier recognises the key and returns the first shipment again. The crucial detail is that the key is created outside the retry loop; created inside it, every attempt becomes a new operation. This only works if the other side supports Idempotency-Key; if it does not, do not retry the POST.
Bookstore: Parcel Tracking
All the pieces come together in src/integrations/carrierClient.ts. Every attempt gets its own timeout, linked to the caller's signal, and whatever the response, its body is either read or cancelled:
// One attempt: own timeout, linked to the caller's signal; only 2xx and 404 come back
async #send(url: URL, init: RequestInit, signal?: AbortSignal): Promise<Response> {
const timeout = AbortSignal.timeout(this.#options.timeoutMs);
const combined = signal ? AbortSignal.any([signal, timeout]) : timeout;
let response: Response;
try {
response = await fetch(url, {
...init,
headers: { accept: "application/json", "x-api-key": this.#options.apiKey, ...init.headers },
signal: combined,
// A Node.js extension to fetch: which undici dispatcher (connection pool) to use
...(this.#options.dispatcher ? { dispatcher: this.#options.dispatcher } : {}),
} as RequestInit);
} catch (err) {
if (signal?.aborted) {
// The caller gave up (client disconnected): not the carrier's fault, do not retry
throw err;
}
const timedOut = timeout.aborted;
throw new CarrierUnavailableError(timedOut ? `carrier did not answer within ${this.#options.timeoutMs} ms` : "carrier unreachable", {
timedOut,
cause: err,
});
}
if (response.ok || response.status === 404) {
return response;
}
// Read (or cancel) every body, otherwise the connection stays busy until garbage collection
await response.body?.cancel();
if (RETRYABLE_STATUS.has(response.status)) {
throw new CarrierUnavailableError(`carrier answered ${response.status}`, {
status: response.status,
retryAfterMs: parseRetryAfter(response.headers.get("retry-after")),
});
}
throw new Error(`carrier rejected the request with ${response.status}`);
}
Network errors and timeouts become a CarrierUnavailableError and are retried. If the caller aborted, the error goes up as it is. A 404, and any 4xx other than the retryable ones, is not retried, because the same request would get the same answer. getTracking turns the 404 into a TrackingNotFoundError. In the API, a new endpoint used only by store staff (StoreStaff) calls this client:
// GET /api/shipments/:trackingNumber: back office staff look up a parcel at the carrier
export function shipmentsRouter(carrier: TrackingLookup): Router {
return Router().get("/:trackingNumber", async (req, res) => {
const { trackingNumber } = req.params;
if (!TRACKING_NUMBER.test(trackingNumber)) {
throw new HttpError(400, "tracking number must be 6-20 upper-case letters or digits");
}
// The request's signal: if the client leaves, the carrier call and its retries stop too
res.json(await carrier.getTracking(trackingNumber, currentRequestContext()?.signal));
});
}
In server.ts we create an Agent, like the SQL pool in post 13, pass it to the client, and tell the graceful shutdown from post 14 to call carrierAgent.close() after the server has closed. The error handler maps TrackingNotFoundError to 404 and CarrierUnavailableError to 504 for a timeout, 502 otherwise. For development we added a fake carrier API, scripts/dev-carrier.ts; with CARRIER_FAIL_EVERY=3 it answers only one request in three. We tried it with labs/18/serve.ts, which starts the API with in-memory repositories:
curl -s -H "authorization: Bearer lab-staff" http://127.0.0.1:52186/api/shipments/BK1001TR
{"level":"warn","time":"2026-11-15T07:23:08.307Z","service":"bookstore-api","requestId":"10661d5b-7565-48c1-b8e9-639d5397e14e","attempt":1,"delayMs":1000,"reason":"carrier answered 503","msg":"retrying carrier call"}
{"level":"warn","time":"2026-11-15T07:23:09.309Z","service":"bookstore-api","requestId":"10661d5b-7565-48c1-b8e9-639d5397e14e","attempt":2,"delayMs":1000,"reason":"carrier answered 503","msg":"retrying carrier call"}
{"trackingNumber":"BK1001TR","status":"in_transit","events":[{"at":"2026-11-14T09:12:00Z","status":"label_created","location":"Istanbul"},{"at":"2026-11-14T18:40:00Z","status":"in_transit","location":"Istanbul hub"}]}
After two 503s the third attempt succeeded. Because Retry-After: 1 came back, each wait is 1000 ms; the requestId in the log lines comes from the AsyncLocalStorage of post 07. Then we stopped the fake carrier and sent the same request again (the log line is shortened):
{"level":"warn","time":"2026-11-15T07:23:12.852Z","service":"bookstore-api","requestId":"fe7c7231-b954-4f1b-b1ec-64fd97d6108e","attempt":1,"delayMs":187,"reason":"carrier unreachable","msg":"retrying carrier call"}
{"level":"warn","time":"2026-11-15T07:23:13.042Z","service":"bookstore-api","requestId":"fe7c7231-b954-4f1b-b1ec-64fd97d6108e","attempt":2,"delayMs":330,"reason":"carrier unreachable","msg":"retrying carrier call"}
{"level":"warn","requestId":"fe7c7231-b954-4f1b-b1ec-64fd97d6108e","err":{"type":"CarrierUnavailableError","message":"carrier unreachable: fetch failed: connect ECONNREFUSED 127.0.0.1:52188","stack":"CarrierUnavailableError: carrier unreachable ...","timedOut":false,"name":"CarrierUnavailableError"},"msg":"carrier call failed"}
{"type":"https://tools.ietf.org/html/rfc9110#section-15.6.3","title":"Bad Gateway","status":502,"detail":"the carrier service is not available, try again later","instance":"/api/shipments/BK1001TR","traceId":"fe7c7231-b954-4f1b-b1ec-64fd97d6108e"}
502 0.531372
This time the error is ECONNREFUSED with no Retry-After; the waits are jittered (187 and 330 ms), and the client got a 502 after 531 ms. The connection error's details are only in the log; the Problem Details sent to the client contain no internal address (post 15).
Testing: undici MockAgent
In post 16 we tested routes with a fake repository. The same goes for an HTTP client: in the route test, a fake object that implements the TrackingLookup interface is enough. To test the client itself, undici has MockAgent; passed as the dispatcher, it answers requests without going to the network. The .NET counterpart is replacing the HttpMessageHandler with a fake, or WireMock.Net:
const tracking = { trackingNumber: "BK1001TR", status: "in_transit", events: [] };
describe("CarrierClient", () => {
let agent: MockAgent;
let retries: number[];
let client: CarrierClient;
beforeEach(() => {
// No real network: requests that no interceptor matches fail instead of leaving the machine
agent = new MockAgent();
agent.disableNetConnect();
retries = [];
client = new CarrierClient({
baseUrl: BASE,
apiKey: "fake-carrier-key-for-tests",
timeoutMs: 200,
attempts: 3,
dispatcher: agent,
onRetry: ({ delayMs }) => retries.push(delayMs),
});
});
afterEach(async () => {
agent.assertNoPendingInterceptors();
await agent.close();
});
it("retries a 503 and honours Retry-After", async () => {
const carrier = agent.get(BASE);
carrier.intercept({ path: "/v1/tracking/BK1001TR" }).reply(503, "", { headers: { "retry-after": "0" } });
carrier.intercept({ path: "/v1/tracking/BK1001TR", headers: { "x-api-key": "fake-carrier-key-for-tests" } }).reply(200, tracking);
assert.deepEqual(await client.getTracking("BK1001TR"), tracking);
assert.deepEqual(retries, [0]);
});
it("does not retry a 404", async () => {
agent.get(BASE).intercept({ path: "/v1/tracking/BK0000TR" }).reply(404, { error: "unknown" });
await assert.rejects(client.getTracking("BK0000TR"), TrackingNotFoundError);
assert.deepEqual(retries, []);
});
it("gives up after three slow answers", async () => {
agent.get(BASE).intercept({ path: "/v1/tracking/BK1001TR" }).reply(200, tracking).delay(500).times(3);
await assert.rejects(client.getTracking("BK1001TR"), (err: unknown) => err instanceof CarrierUnavailableError && err.timedOut);
assert.equal(retries.length, 2);
});
node --test tests/unit/carrierClient.test.ts tests/api/shipments.api.test.ts
✔ returns the carrier's tracking for store staff (8.785583ms)
✔ is only for store staff (1.549917ms)
✔ maps the carrier's 404 and its timeouts (1.998916ms)
▶ CarrierClient
✔ retries a 503 and honours Retry-After (10.014583ms)
✔ does not retry a 404 (0.723667ms)
✔ gives up after three slow answers (1027.132542ms)
✔ stops retrying when the caller aborts (54.333625ms)
✔ CarrierClient (1093.033916ms)
ℹ tests 7
ℹ pass 7
ℹ fail 0
disableNetConnect() keeps a request that matches no interceptor from reaching the real network; assertNoPendingInterceptors() checks that every response you defined was actually used. The slow-answer test runs on real time, so it takes about a second; that is why the durations in the test are small. The msw package does the same at a higher level; look at it if you want to run browser and Node tests from the same definitions.
Production Notes
Think about the total time. Three attempts of 2 seconds each, plus the waits between them, can take a request close to 7 seconds. The API's own requestTimeout (we set it to 30 seconds in post 11) and the timeout of the load balancer in front of it must be longer; otherwise you keep retrying after your client has given up. Always pass the caller's signal on.
Measure. Log or record as a metric every attempt, its duration and its outcome; that is the only way to see how often the carrier returns 503. undici publishes request start and finish events through node:diagnostics_channel; OpenTelemetry's undici instrumentation builds on them (the Observability series).
If the external service is down for a long time, trying it three times on every request only adds load. That is where a circuit breaker comes in. Node has no built-in one; there are packages such as opossum, and post 04 of the External Service Integration and Resilience series explains the concept and the .NET implementation.
Common Pitfalls
Not checking response.ok. fetch does not reject on 4xx and 5xx; the error message gets processed as data.
No timeout. The defaults are 300 seconds; one hanging external service keeps the whole API waiting.
Leaving the body unread. Call cancel() even on error responses, or the connection pool runs dry.
Retrying a POST without a key. After a timeout you cannot know whether the other side did the work.
Creating a new Agent for every call. All the benefit of keep-alive is lost; it is the Node version of the new HttpClient() mistake.
A Small Experiment
★☆☆ Fix labs/18/guess.ts with a response.ok check: on a 404, throw an error that contains the status and the error message from the body.
★★☆ Add a third strategy to labs/18/herd.ts: half of the wait fixed, half random ("equal jitter"). Compare the spread of the waves and the finishing time with full jitter.
★★★ Add a simple circuit breaker to CarrierClient: after 5 CarrierUnavailableErrors in a row, fail for 30 seconds without calling the carrier at all, then let a single trial request through. Test it with MockAgent.
In the next post we leave HTTP for the terminal: with util.parseArgs, exit codes, readline and child_process we will write a CLI tool for Bookstore.