`AxiosError.prototype.toJSON()` can OOM when config includes an `httpAgent`/`httpsAgent`
Problem
Calling error.toJSON() on an AxiosError (e.g. from an error logger such as pino, winston, or Sentry) can cause unbounded memory growth and crash the process with JavaScript heap out of memory when the request config includes a Node http.Agent/https.Agent, even under moderate concurrency.
This is a realistic, common configuration — using a shared httpAgent/httpsAgent for connection reuse is a widely recommended pattern — so any failed request logged through a standard error logger can trigger it.
Reproduction
import http from 'node:http';
import https from 'node:https';
import axios from 'axios';
const server = http.createServer((req, res) => {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ message: 'duplicate key error' }));
});
server.listen(0, async () => {
const { port } = server.address();
const instance = axios.create({
httpAgent: new http.Agent(),
httpsAgent: new https.Agent(),
timeout: 5000,
});
await Promise.all(Array.from({ length: 25 }, () =>
instance.get(`http://127.0.0.1:${port}/`).catch((error) => {
error.toJSON(); // realistic call site: any error logger
})
));
server.close();
});
Run with:
node --max-old-space-size=512 repro.mjs
Result:
FATAL ERROR: Ineffective mark-compacts near heap limit Allocation failed - JavaScript heap out of memory
Environment
- axios 1.2.0 through current (verified against latest
v1.x) - Node.js (reproduced on v26.7.0; the Agent/Socket object shape causing this is stable across Node versions)
Early Copilot findings / suggestions
The following analysis was produced with AI assistance to help maintainers get started faster. It has not been independently verified by a human and should be treated as a starting point, not a conclusion.
Suspected root cause
AxiosError.prototype.toJSON() calls utils.toJSONObject(this.config) unconditionally. toJSONObject's cycle detection is ancestor-path-only (it tracks the current recursion path, not a global "already visited" set). This appears intentional — it preserves correct serialization of shared sibling/DAG references instead of silently dropping them. The tradeoff: any object reachable via multiple distinct paths gets fully re-serialized once per path.
Node's http(s).Agent / Socket / TLSSocket internals appear to have exactly this shape — heavily cross-referenced (agent ↔ sockets ↔ parser ↔ request ↔ response ↔ socket). Serializing config.httpAgent/httpsAgent therefore walks and re-walks large, overlapping subgraphs, and the cost seems to compound with concurrent in-flight requests sharing one agent.
Bisect
Bisecting against the repro above points to the origin as commit b7ee49f6 (PR #5247, "Added toJSONObject util", merged 2022-11-22, first shipped in axios 1.2.0), which changed AxiosError.prototype.toJSON() from:
config: this.config
to:
config: utils.toJSONObject(this.config)
i.e. from a shallow reference to a recursive deep-clone. The repro passes cleanly against the parent commit (a372b4ce) and OOMs immediately on b7ee49f6 itself, with the same script and same --max-old-space-size limit.
Also checked: the later refactor in PR #10832 ("use WeakSet for cycle detection in toJSONObject") does not appear to be related. That PR only swapped the cycle-detection data structure (Array stack → WeakSet) while preserving identical ancestor-path-only semantics. The repro crashes equally (same magnitude, same threshold) on commits immediately before and after #10832.
Possible directions (unverified, for discussion)
- Don't deep-serialize
httpAgent/httpsAgent(and similar platform handles) inAxiosError.toJSON()— replace with a shallow marker (e.g.'[http.Agent]') rather than recursing into Node internals. - Add a depth or node-count budget to
toJSONObjectto bound worst-case work regardless of graph shape. - Consider excluding known Node core objects (
net.Socket,tls.TLSSocket,http.Agent,http.IncomingMessage,http.ClientRequest) from recursive serialization by default.
Source: axios/axios