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.
Step-by-Step Fix
- 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. - Bound sockets per origin. Set
maxSocketsforhttp.Agentorconnectionsfor an undiciAgent/Poolfrom the upstream’s capacity and your concurrency. Verification: under a burst, open sockets per origin never exceed the bound. - Bound the idle pool. Set
maxFreeSocketsto what steady traffic needs and atimeout(or undicikeepAliveTimeout) shorter than the upstream server’s idle timeout. Verification: after a peak, free sockets fall back within the timeout. - 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. - Destroy agents with lifetimes. An agent created for a tenant or a job must be destroyed with
agent.destroy()(orawait dispatcher.close()) when it is done. Verification: no sockets for retired tenants remain. - Export pool metrics. Report in-use, free and queued counts per origin. Verification: dashboards show pool sizes alongside request rate.
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.
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.
Related
- Connections, Sockets and EventEmitter Memory — the parent topic
- Unconsumed Fetch Response Bodies in Node.js — sockets held by unread responses
- WebSocket Server Memory per Connection — the inbound side
- MaxListenersExceededWarning and EventEmitter Leaks — listeners on reused sockets