Node.js

Node.js — Undici / fetch keep-alive socket exhaustion

Written and reviewed by Sahil Srivastav

UndiciResponse ownershipHTTP concurrency
TypeError: fetch failed

What this error actually means

An HTTP connection is not necessarily reusable when fetch resolves. The promise can resolve after headers arrive while the response body remains unread. The client still has to consume or cancel that body according to the API contract. Ignoring it leaves resource release dependent on behaviour that is unsuitable as a connection-management strategy.

With a bounded dispatcher, occupied connections produce a growing request queue. With excessive connection creation or concurrency, pressure can instead appear as descriptor usage, connection failures or ephemeral-port churn. TypeError: fetch failed is a broad wrapper, not a unique socket-exhaustion diagnosis; inspect its cause and the pool’s behaviour.

Built-in fetch uses the Undici version bundled with Node, while an installed undici package has its own release and APIs. Core http.Agent configuration is not automatically the fetch dispatcher configuration. Establish which client actually performs the request before changing keep-alive settings.

Causes, most common first

  1. 1Response bodies are abandoned on a branch. Code throws on !response.ok before consuming or cancelling the error body, or returns after inspecting headers. Error responses can have substantial bodies too. Every branch receiving a Response needs an explicit decision about its body lifecycle.
  2. 2A dispatcher is created for every request. Repeated new Agent or Pool calls create separate connection ownership domains. If they are never closed, the process accumulates sockets and timers; if closed immediately, it sacrifices reuse and generates connection churn. Share an appropriately scoped dispatcher across operations.
  3. 3Unbounded concurrency exceeds downstream capacity. Promise.all over an entire dataset initiates far more work than the connection pool can serve promptly. Queued requests retain application state, and callers may time out while work remains queued. A connection cap without an admission bound merely moves the backlog into the client.
  4. 4Slow or endless bodies occupy connections. Headers arrive quickly but the body stalls or is unexpectedly large. A timeout covering only the initial fetch resolution may miss body consumption. Apply one operation budget through the entire read and propagate caller cancellation.

When you see it

  • The first batch succeeds, then later fetch calls wait while upstream traffic drops
  • Early returns after reading only status or headers correlate with exhaustion
  • Open sockets or TIME_WAIT entries grow rapidly under a steady request rate
  • Increasing pool size delays the stall but does not remove it

How to diagnose it

Step 1

Capture client and runtime identity

Record Node and the bundled Undici version when exposed, and separately inspect the installed package. Do not assume an example using an installed Pool also describes the built-in global dispatcher configuration.

node -p "JSON.stringify({ node: process.version, undici: process.versions.undici })"
npm ls undici

Step 2

Inspect the wrapped network cause

Log the top-level message and safe fields from error.cause, including its code. Connection refusal, DNS failure and a local abort need different repairs. Avoid flattening every fetch failure into a generic retry without preserving those distinctions.

Step 3

Audit every response branch

For each fetch call, follow success, non-2xx, redirect handling, parser failure and early return. Verify the body is consumed or cancelled. Test an upstream that sends headers immediately but delays a sizeable body; tiny responses may mask abandoned-body bugs.

Step 4

Correlate socket state with queueing

On Linux, inspect connections to the upstream port and compare with application in-flight counts. Many established sockets and no completions suggest retained or stalled bodies; many short-lived connections suggest dispatcher churn. Run in the same network namespace as the process.

ss -tanp 'dport = :443'

The fix

Consume required bodies completely and cancel bodies you deliberately abandon. Cancellation may sacrifice that connection’s reuse, but it is a deliberate release decision. When only headers are needed and the upstream supports it correctly, a HEAD request avoids fetching a body altogether.

Create a bounded dispatcher at application startup and reuse it for the intended upstreams. Choose connection and concurrency limits from downstream capacity and latency measurements. Close the dispatcher during shutdown after current operations have settled, rather than per request.

Carry a deadline or abort signal through fetch and body consumption. A response.json call still performs body reading and parsing after fetch resolves. Bound response sizes for untrusted or variable upstreams; replacing an unread body with an unlimited text() call can trade socket exhaustion for memory exhaustion.

Put a bounded work queue before the dispatcher and reject or defer excess admission. Retry only within the remaining operation budget and according to request semantics. Socket churn caused by retries can amplify the original incident even after the connection limit is raised.

import { Pool, fetch } from "undici";

const upstream = new Pool("https://api.example.com", { connections: 8 });

async function readSmallStatus() {
  const response = await fetch("https://api.example.com/status", {
    dispatcher: upstream,
    signal: AbortSignal.timeout(5_000),
  });
  try {
    if (!response.ok) throw new Error(`upstream status ${response.status}`);
    // This endpoint has a separately enforced small-response contract.
    return await response.json();
  } finally {
    if (response.body && !response.bodyUsed) {
      await response.body.cancel();
    }
  }
}

// During shutdown, after admitted operations finish:
// await upstream.close();

How to stop it coming back

  • Review response-body ownership on non-success and early-return paths
  • Test slow bodies as well as slow headers and connection establishment
  • Monitor queued work, active operations and connection creation separately
  • Pin and record Node and installed Undici versions when investigating client behaviour

Practise production debugging in a real repository

Reading about a failure and reproducing one are different skills. Gronex ships broken backend repositories with failing test suites that encode the real invariant, so you debug from evidence instead of memorising symptoms.

FAQ

Will garbage collection release unread responses?

Do not depend on it. Explicit body consumption or cancellation gives the connection a defined lifecycle. Garbage collection timing is not an HTTP admission or resource-release policy.

Does setting http.globalAgent.maxSockets configure fetch?

Core http.Agent and the Undici dispatcher are separate client abstractions. Identify whether the call uses core http, built-in fetch or installed Undici and configure the corresponding implementation.

Should I drain every error body with response.text()?

Only if its size is bounded and consuming it fits the operation budget. An unlimited error body can exhaust memory. Cancel an unwanted body or use a bounded draining strategy appropriate to the API.

Related

Other errors engineers hit next to this one

Full error and symptom index →