When a long-lived AbortSignal is reused across many fetch() calls, event listeners accumulate on the signal and are never removed after the request completes. This causes linear RSS growth proportional to the number of completed requests.
Root cause
Two listeners are registered on the signal per request and neither is cleaned up:
1. fetch() — waitForAbort promise (index.wrapper.js)
const waitForAbort = new Promise((_, reject) => {
signal?.addEventListener?.("abort", () => {
reject(signal.reason);
}, { once: true });
});
If the signal is never aborted, this promise never settles and the listener persists on the signal indefinitely, preventing GC of the closure and everything it captures.
2. #wrapResponse() — response abort listener
signal?.addEventListener?.("abort", () => {
originalResponse.abort();
});
This listener holds a reference to originalResponse (native Rust object) and is registered without { once: true }. It is never removed after the response is consumed.
Reproduction
import { Impit } from "impit";
const controller = new AbortController();
const impit = new Impit({ browser: "chrome" });
for (let i = 0; i < 5000; i++) {
const response = await impit.fetch("https://example.com", {
signal: controller.signal,
});
await response.text();
if (i % 100 === 0) {
console.log(`${i} requests, RSS: ${Math.round(process.memoryUsage().rss / 1024 / 1024)} MB`);
}
}
Expected: RSS stabilizes after warmup.
Actual: RSS grows linearly with each request.
Impact
In a worker processing queue tasks at high concurrency (100 concurrent requests sharing one AbortController), RSS grows ~90 MB/min and the process OOM-kills within minutes on memory-constrained environments (e.g. 1Gi Cloud Run).
Suggested fix
Remove listeners after the request completes, e.g. via AbortController per request internally or explicit removeEventListener in a finally block.
When a long-lived
AbortSignalis reused across manyfetch()calls, event listeners accumulate on the signal and are never removed after the request completes. This causes linear RSS growth proportional to the number of completed requests.Root cause
Two listeners are registered on the signal per request and neither is cleaned up:
1.
fetch()—waitForAbortpromise (index.wrapper.js)If the signal is never aborted, this promise never settles and the listener persists on the signal indefinitely, preventing GC of the closure and everything it captures.
2.
#wrapResponse()— response abort listenerThis listener holds a reference to
originalResponse(native Rust object) and is registered without{ once: true }. It is never removed after the response is consumed.Reproduction
Expected: RSS stabilizes after warmup.
Actual: RSS grows linearly with each request.
Impact
In a worker processing queue tasks at high concurrency (100 concurrent requests sharing one AbortController), RSS grows ~90 MB/min and the process OOM-kills within minutes on memory-constrained environments (e.g. 1Gi Cloud Run).
Suggested fix
Remove listeners after the request completes, e.g. via
AbortControllerper request internally or explicitremoveEventListenerin afinallyblock.