Unconsumed fetch Response Bodies in Node.js
A service that calls other APIs with the built-in fetch slowly runs out of connections, requests start queueing, and memory climbs with retained response buffers — all because some code paths check res.status and return without ever reading the body. This guide from Node.js Stream Backpressure and Memory Growth, in Node.js Server-Side Memory Management, explains why an unread body holds resources in Node’s fetch implementation, how to find the leaking paths, and the one-line habits that prevent it.
| Symptom | Root Cause | Immediate Action | Measurable Impact |
|---|---|---|---|
| Outbound requests queue; pool exhausted | Unread bodies keep connections busy | Consume or cancel every body | Connections returned to the pool |
| Memory grows with outbound error responses | Error bodies never read, buffered data retained | await res.body?.cancel() on early returns |
Buffers released promptly |
| Latency spikes after upstream errors | Pool occupied by stuck responses | Consume in finally blocks |
Stable latency during upstream failures |
| Leak disappears only after GC | Relying on garbage collection to close bodies | Explicit cancel; never rely on GC | Deterministic release |
| Streaming downloads left half-read | Early break or error without cleanup |
Cancel the body in finally |
Socket closed or reused |
Root Cause: A Response Is an Open Stream Until You Finish It
Node’s global fetch is implemented by undici. When fetch() resolves, you have the status and headers, but the body is a ReadableStream that is still connected to the underlying socket. Undici reads body data as you consume it — res.json(), res.text(), res.arrayBuffer() or iterating res.body — and only when the body is fully consumed (or cancelled) can the connection be returned to the keep-alive pool for the next request, or closed.
If code returns without touching the body — if (!res.ok) return null;, if (res.status === 304) return cached;, if (res.headers.get('content-type') !== 'application/json') throw … — the response stream stays open. Undici has internal safeguards and eventually cleans up bodies whose Response objects are garbage collected, but that depends on GC timing and the project’s documentation explicitly advises against relying on it. Until then, the connection is not available to other requests, any data already received sits in stream buffers, and the Response object with its closures remains reachable from the connection. Under load, especially when an upstream starts returning errors, the pool fills with stuck connections: outbound calls queue, timeouts grow, and memory follows — a failure pattern that looks like a leak in RSS vs heapUsed graphs because part of the buffered data is outside the JS heap.
The fix is a rule, not an optimisation: every response body is either consumed or cancelled. For bodies you need, read them. For bodies you do not, call res.body?.cancel() (or await res.arrayBuffer() for tiny bodies when reusing the connection matters more than the read). Streaming consumers that stop early should cancel in a finally block — the reading-side counterpart of the rules in async iterators and stream backpressure.
Step-by-Step Fix
- Find early returns after
fetch. Search forawait fetch(and inspect every path that returns or throws before reading the body: status checks, header checks, redirects handled manually, cached 304 responses. Verification: you have a list of unconsumed paths. - Cancel bodies you do not need. On those paths, call
await res.body?.cancel()before returning or throwing. Verification: code review shows no path that leavesreswithout consuming or cancelling. - Centralise with a helper. Wrap
fetchin a small function that guarantees consumption (see code), and use it for internal service calls. Verification: call sites no longer handle bodies manually. - Cancel streaming bodies in
finally. When iteratingres.bodyand stopping early, cancel in afinallyblock. Verification: aborting a download mid-way releases the connection. - Add timeouts. Use
AbortSignal.timeout(ms)on requests so stalled upstreams cannot hold connections forever. Verification: stuck requests end after the timeout and release resources. - Load-test with upstream errors. Make the upstream return 500s and 404s for a portion of calls under load. Verification: pool utilisation, latency and memory stay stable instead of degrading.
Command and Code Reference
Use case: a JSON helper that always consumes or cancels.
// http.js — internal service calls
export async function getJson(url, { timeoutMs = 5000, ...init } = {}) {
const res = await fetch(url, { ...init, signal: AbortSignal.timeout(timeoutMs) });
if (!res.ok) {
// Read a little for diagnostics, or cancel outright — never leave it open
const detail = await res.text().catch(() => '');
throw new HttpError(res.status, detail.slice(0, 500));
}
return res.json(); // consumes the body fully
}
Use case: early returns that cancel.
const res = await fetch(url, { headers: { 'if-none-match': etag } });
if (res.status === 304) {
await res.body?.cancel(); // nothing to read, release the connection now
return cached;
}
if (!res.headers.get('content-type')?.includes('application/json')) {
await res.body?.cancel();
throw new Error('unexpected content type');
}
return res.json();
Use case: streaming download that may stop early.
async function firstMatchingLine(url, predicate) {
const res = await fetch(url);
const reader = res.body.pipeThrough(new TextDecoderStream()).getReader();
try {
let buf = '';
for (;;) {
const { value, done } = await reader.read();
if (done) return null;
buf += value;
const lines = buf.split('\n');
buf = lines.pop();
const hit = lines.find(predicate);
if (hit) return hit; // early exit…
}
} finally {
await reader.cancel().catch(() => {}); // …always releases the stream and connection
}
}
Verification and Regression Prevention
Verify under a failure-heavy load test: when the upstream returns a large share of errors, connection pool usage should stay within its normal range, outbound latency should not climb, and memory should remain flat. In production, export pool statistics where your HTTP client exposes them, and alert on sustained queueing of outbound requests.
Prevent regressions with a lint rule (or code review checklist) that flags await fetch( whose result is used only for status, ok or headers, and by routing service-to-service calls through the helper above. Combine request timeouts with the broader connection guidance in HTTP keep-alive agents and socket pool memory.
Edge Cases and Gotchas
HEAD requests and empty bodies
Responses without a body (HEAD, 204, most 304s) have res.body === null, so optional chaining (res.body?.cancel()) is safe everywhere.
Reading error bodies for diagnostics
Reading a small part of an error body helps debugging, but large error pages (HTML from proxies) can be sizeable. Read with a limit or cancel after capturing the status.
response.clone()
Cloning a response tees the body; both branches must be consumed or cancelled, or the unread branch buffers data from the other. Avoid cloning unless you need both copies.
Undici’s own request API
When using undici directly (request()), the same rule applies: consume the body or call body.dump() to discard it and free the connection.
Frequently Asked Questions
Do I have to read the body of every fetch response in Node.js?
Yes — or explicitly cancel it. Until the body is consumed or cancelled, the underlying connection cannot be reused or released reliably, and buffered data stays in memory. Relying on garbage collection to clean up is discouraged.
How do I discard a response body I don’t need?
Call await res.body?.cancel(). It releases the stream immediately. For very small bodies on keep-alive connections, reading them fully (await res.arrayBuffer()) can let the connection be reused, but cancelling is the simplest safe default.
Why does my service hang when an upstream returns errors?
Error-handling paths often return without reading the body, so each failed call leaves a connection busy. With enough errors the pool is exhausted and new requests queue. Cancel bodies on every early return.
Does this apply to fetch in the browser too?
Browsers also keep response streams open until they are read or cancelled, but connection pools and memory are managed per page by the browser, and the impact on long-running servers is much greater. The same habit of consuming or cancelling is good practice in both.
How can I detect unconsumed bodies in tests?
Run integration tests against a local server that counts open connections, and assert that the count returns to the pool size after each test, including tests that exercise error responses. A growing number of busy connections after error-path tests points to a missing cancel.
Does the same apply to axios or got?
Libraries that buffer responses fully by default read the body for you, so the problem mostly appears with streaming responses you do not finish. Whenever you request a stream — from any client — consume it to the end or destroy it.
Should I configure the global dispatcher?
Tuning the undici agent’s connection limits and keep-alive timeouts helps capacity planning, but it does not fix unread bodies — it only changes how soon the pool runs out. Fix consumption first, then tune pool sizes from measured concurrency.
Will a timeout clean up an unread body?
An AbortSignal passed to fetch aborts the request and its body stream when it fires, which releases resources. Timeouts are a safety net for stalled upstreams, not a substitute for consuming bodies on normal paths.
Related
- Node.js Stream Backpressure and Memory Growth — the parent topic
- Pipeline vs pipe for Memory-Safe Streams — cleanup when connecting streams
- Unsettled Promises That Leak Their Closures — timeouts and cancellation for pending work
- Node.js Server-Side Memory Management — the section overview