HTTP Keep-Alive Agents and Socket Pool Memory

Your service calls a handful of internal APIs. Under load, the number of open outbound sockets climbs into the thousands, memory rises with it, and after the peak both stay high for a long time. Or file descriptors run out and new requests fail with EMFILE. The usual cause is how HTTP agents pool sockets: an agent per request, unbounded pools, or idle sockets kept long after the peak. This guide from Connections, Sockets and EventEmitter Memory, part of Node.js Server-Side Memory Management, covers both http.Agent and the undici dispatcher behind fetch.

Symptom Root Cause Immediate Action Measurable Impact
Outbound socket count grows with request volume A new http.Agent (or undici Agent) created per request Create one agent per upstream at startup and reuse it Sockets reused; count bounded
Thousands of sockets to one host during peaks maxSockets defaults to unlimited Set maxSockets (or undici connections) per origin Bounded concurrency and memory
Memory stays high long after a peak Many idle sockets kept (maxFreeSockets high, long idle timeout) Lower maxFreeSockets; set timeout Pool shrinks after peaks
Sockets never return to the pool Response bodies not consumed or destroyed Always read, resume() or cancel bodies Stable pool, no stalls
EMFILE or connection resets File descriptor exhaustion or server idle timeout shorter than client’s Bound pools; keep client idle timeout below the server’s No resets or descriptor errors

Root Cause: A Pool Per Agent, Per Origin

An agent manages connections for outbound requests. For each origin (host and port), http.Agent keeps two collections: agent.sockets, the sockets currently in use, and agent.freeSockets, idle keep-alive sockets waiting to be reused. When a request needs a socket, the agent reuses a free one or opens a new one, up to maxSockets per origin; beyond that, requests queue in agent.requests. When a response finishes and keep-alive is enabled, the socket moves to the free list, up to maxFreeSockets, until it times out.

Every socket costs memory: kernel buffers, a native handle, TLS state for HTTPS, and the JavaScript Socket with its stream state, as described in the connections and sockets topic. Pooling bounds that cost only if the pool is shared and limited. Three patterns break it. An agent per request creates a fresh pool every time, so no socket is reused and each keep-alive socket lingers until its idle timeout. Unlimited maxSockets (the default for http.Agent) lets a burst of concurrent requests open a socket per request. A large free list keeps peak-sized pools open long after traffic falls.

fetch in Node.js uses undici, which has its own dispatcher: a global Agent that creates a Pool per origin. Its connections option (unlimited by default) bounds sockets per origin, and keepAliveTimeout controls how long idle sockets stay. The same three mistakes apply — creating a new dispatcher per request, leaving connections unbounded, and keeping idle sockets too long. A fourth applies to both clients: a response whose body is never consumed holds its socket, as covered in unconsumed fetch response bodies.

Agent per request versus shared agent With a new agent per request, each request opens its own socket in its own pool, nothing is reused, and every socket lingers idle until its timeout, so socket count follows request volume. With one shared agent per upstream, requests borrow sockets from a pool bounded by maxSockets, idle sockets up to maxFreeSockets are reused, and extra requests queue. new Agent() per request req 1 → sock req 2 → sock req 3 → sock req 4 → sock req 5 → sock req 6 → sock no reuse; each socket idles until timeout socket count follows request volume one shared agent per upstream in use ≤ maxSockets free (idle) ≤ maxFreeSockets sockets reused; extra requests queue idle sockets close after timeout

Step-by-Step Fix

  1. Find agent construction. Search for new http.Agent, new https.Agent, new Agent( (undici) and client libraries configured inside request handlers. Verification: every agent is created once at module scope or startup.
  2. Bound sockets per origin. Set maxSockets for http.Agent or connections for an undici Agent/Pool from the upstream’s capacity and your concurrency. Verification: under a burst, open sockets per origin never exceed the bound.
  3. Bound the idle pool. Set maxFreeSockets to what steady traffic needs and a timeout (or undici keepAliveTimeout) shorter than the upstream server’s idle timeout. Verification: after a peak, free sockets fall back within the timeout.
  4. Consume every response. Read the body, call res.resume(), or cancel the fetch body on every path including errors. Verification: in-use socket count returns to zero when traffic stops.
  5. Destroy agents with lifetimes. An agent created for a tenant or a job must be destroyed with agent.destroy() (or await dispatcher.close()) when it is done. Verification: no sockets for retired tenants remain.
  6. Export pool metrics. Report in-use, free and queued counts per origin. Verification: dashboards show pool sizes alongside request rate.
Open sockets through a traffic peak During a traffic peak, an unbounded pool opens a socket for each concurrent request and, with a large free list and long idle timeout, keeps most of them open long after the peak. A pool bounded by maxSockets is capped during the peak, queues the excess, and shrinks to steady-state size within the idle timeout. sockets unbounded: stays near peak maxSockets cap bounded: shrinks after timeout traffic peak, then steady load

Command and Code Reference

Use case: one bounded http.Agent per upstream.

// agents.js — created once at startup, imported everywhere
const https = require('node:https');

const inventoryAgent = new https.Agent({
  keepAlive: true,
  maxSockets: 64,        // at most 64 concurrent sockets to this origin
  maxFreeSockets: 8,     // keep up to 8 idle sockets for reuse
  timeout: 30_000,       // close sockets idle for 30 s (below the server's idle timeout)
  scheduling: 'lifo',    // reuse the most recent socket so older ones can time out
});

module.exports = { inventoryAgent };

Use case: bounding fetch (undici) globally and per origin.

// dispatcher.js — run once at startup
const { Agent, setGlobalDispatcher } = require('undici');

setGlobalDispatcher(new Agent({
  connections: 64,           // sockets per origin
  keepAliveTimeout: 10_000,  // idle socket lifetime (ms)
  keepAliveMaxTimeout: 30_000,
}));

// every fetch() now shares these bounded pools

Use case: inspecting pool state.

// log per-origin pool sizes from an http.Agent
function poolStats(agent) {
  const count = (o) => Object.fromEntries(Object.entries(o).map(([k, v]) => [k, v.length]));
  return { inUse: count(agent.sockets), free: count(agent.freeSockets), queued: count(agent.requests) };
}
setInterval(() => console.log(poolStats(inventoryAgent)), 60_000).unref();

Verification and Regression Prevention

Verify with a load test that includes a peak and a quiet period. During the peak, in-use sockets per origin should stop at the bound and excess requests should queue briefly rather than open new sockets. After the peak, free sockets should fall to the steady-state level within the idle timeout. After traffic stops entirely, in-use sockets should be zero — anything else points to unconsumed responses.

Add a lint rule or code-review check that forbids constructing agents or dispatchers inside functions that run per request, and keep the pool metrics on the service dashboard. A rising queued count means the bound is too tight for the traffic; a rising free count with falling traffic means the idle timeout is too long.

Reading pool metrics after a peak After traffic stops, in-use sockets should be zero and free sockets should fall to steady state within the idle timeout. In-use sockets stuck above zero point to unconsumed response bodies. A rising queued count during normal load means maxSockets is too tight. Free sockets staying high means the idle timeout or maxFreeSockets is too generous. Pool metrics after the peak Healthy: bounded and releasing in-use 0, free shrinks Unconsumed bodies hold sockets in-use stuck above 0 maxSockets too tight for the traffic queued rising Lower maxFreeSockets or the idle timeout free stays high

Edge Cases and Gotchas

The global agent defaults changed

Since Node.js 19, the default global HTTP agent uses keep-alive with a short idle timeout. Code that relied on sockets closing after each request now keeps them briefly; code that set keepAlive: true explicitly on a custom agent is unaffected. Configure agents explicitly rather than relying on defaults.

Server idle timeout shorter than the client’s

If the upstream closes idle connections after 5 seconds and your agent keeps them for 30, requests sent on a just-closed socket fail with ECONNRESET. Keep the client’s idle timeout below the server’s.

SDKs create their own agents

Cloud SDKs and API clients often create agents internally. Creating a new SDK client per request creates a new pool per request. Create clients once and reuse them.

Many distinct origins

Pools are per origin. A service calling thousands of distinct hosts (webhooks, crawlers) holds a pool for each; bound maxTotalSockets across origins and keep free lists small.

Frequently Asked Questions

Should I enable keepAlive for internal service calls?

Yes, usually. Reusing sockets avoids connection and TLS setup for every request. Bound the pool with maxSockets and maxFreeSockets and set an idle timeout so reuse does not become unbounded retention.

What is the default maxSockets?

For http.Agent, unlimited (Infinity). A burst of concurrent requests to one origin can open one socket per request. Set a bound that matches the upstream’s capacity.

How do I limit sockets for fetch in Node.js?

fetch uses undici’s global dispatcher. Replace it at startup with setGlobalDispatcher(new Agent({ connections: N })), or pass a dispatcher option to individual fetch calls.

Why do sockets stay in use after requests finish?

Usually because the response body was not consumed. A socket returns to the pool only after the response ends; call res.resume(), read the body, or cancel it on every path, including errors.

How much memory does an idle keep-alive socket use?

A few kilobytes to a few tens of kilobytes including kernel buffers and TLS state. The total matters when pools hold hundreds or thousands of idle sockets, which is why maxFreeSockets and idle timeouts are worth setting.

When should I call agent.destroy()?

When the agent’s lifetime ends before the process does: per-tenant agents, agents for batch jobs, or clients used in tests. It closes all sockets immediately.

Do database and Redis clients have the same issue?

Yes. They pool connections in the same way, with their own options for pool size and idle timeouts. Create each client once at startup, release checked-out connections in finally, and export the client’s total and idle counts next to your HTTP agent metrics.