Repository navigation
Memory leak, instrumentXHR virtualError retains every XMLHttpRequest when requests are chained from readystatechange #24249
Description
Is there an existing issue for this?
- I have checked for existing issues https://github.com/getsentry/sentry-javascript/issues
- I have reviewed the documentation https://docs.sentry.io/
- I am using the latest SDK release https://github.com/getsentry/sentry-javascript/releases
How do you use Sentry?
Sentry Saas (sentry.io)
Which SDK are you using?
@sentry/browser
SDK Version
10.73.0
Framework Version
No response
Link to Sentry event
No response
Reproduction Example/SDK Setup
Minimal reproduction can be found here https://github.com/augustalfredsson/sentry-xhr-leak-repro
The reproduction is one HTML page and a Node server. The page loads the Sentry browser bundle from the CDN, calls
Sentry.init with the default integrations, and runs four request chains. Each chain opens the next
XMLHttpRequest from the readystatechange callback of the previous one:
const next = () => {
const xhr = new XMLHttpRequest();
xhr.open("GET", "/api", true);
xhr.onreadystatechange = () => {
if (xhr.readyState === 4) {
next(); // open the next request on the same call stack
}
};
xhr.send();
};
next();Sentry.init({
dsn: "https://examplePublicKey@o0.ingest.sentry.io/0",
beforeSend: () => null, // the page never sends events
});This is a common pattern: polling loops, chunked uploads, chained REST calls, and hls.js Low-Latency HLS playlist
reloads.
Steps to Reproduce
- Run
node server.js, then openhttp://localhost:8787/?sentry=onin Chrome and press Start. - The page shows the size of the JS heap, but you can also see it in DevTools -> Memory panel and take a heap snapshot after 30 seconds. Filter the class list by
XMLHttpRequest. - Wait one minute and you'll see that the JS heap size keeps growing. Take a second snapshot if you took one earlier. Compare the count and the heap size.
- Open
http://localhost:8787/?sentry=off, press Start, and repeat. This is the control without the Sentry
bundle. You'll see that the JS heap size doesn't grow.
Or run node verify.js 40, which does the same in headless Chromium and counts live XMLHttpRequest objects
through CDP Runtime.queryObjects.
Expected Result
A completed XMLHttpRequest becomes garbage after its callbacks have run and the JS heap size doesn't grow.
Actual Result
Every completed request stays alive for the life of the page. Here's a headless run of 40 seconds with four chains and 8 KB
responses:
sentry=on: completed 224991 requests. Live XMLHttpRequest: 15591 -> 229113. Heap: 28.2 MB -> 400.7 MB
sentry=off: completed 242321 requests. Live XMLHttpRequest: 4 -> 8. Heap: 0.7 MB -> 0.7 MB
The live XMLHttpRequest count equals the number of completed requests. Each retained request also pins its
responseText and everything the app's callback closures reference.
Additional Context
What we think happens
In packages/browser-utils/src/instrument/xhr.ts, the open wrapper
creates const virtualError = new Error() for every request and never releases it. The
setRequestHeader Proxy that the wrapper puts on the XHR instance lives in the same
function scope, so it keeps virtualError alive for as long as the XHR is alive. Nobody
reads virtualError.stack, so V8 keeps the raw stack frames, and each raw frame holds its
receiver object. When request N+1 is opened inside the callback of request N, one of those
frames has request N as its receiver. So request N+1 retains request N, which retains
request N-1, and so on. The request in flight is a Blink pending activity and thus a GC
root, and the whole chain hangs from it.
The path from a heap snapshot:
Blink "Pending activities" (the one in-flight XHR)
-> XMLHttpRequest#N
-> own property `setRequestHeader` (Proxy)
-> Proxy `apply` closure -> closure context of the `open` handler
-> `virtualError` -> raw stack frames
-> frame receiver = XMLHttpRequest#N-1
-> ... -> XMLHttpRequest#N-2 -> ...
Related PR
#22216 (10.66.0) removes the readystatechange listener after the request
completes. That does not break this chain, because the setRequestHeader Proxy still holds
the context.
Possible fixes
Any one of these breaks the chain:
- Set
virtualError = undefinedafter thereadyState === 4handler ran. - Create the
setRequestHeaderProxy in its own function, so it does not share a closure
context withvirtualError. - Keep only the formatted
stackstring, or holdvirtualErrorin aWeakRef.
Workaround
Turn off the XHR instrumentation. Both default integrations call
instrumentXHR, so both options are needed. This loses XHR breadcrumbs.
integrations: [
Sentry.breadcrumbsIntegration({ xhr: false }),
Sentry.browserApiErrorsIntegration({ XMLHttpRequest: false }),
],Environment
Chrome 151.0.7922.176, macOS 26.6.2. Headless numbers from Chromium 1200
(Playwright 1.57) on macOS.
Priority
No response
Metadata
Metadata
Assignees
Projects
- StatusShow more project fieldsNo status
Activity
linear-code commentedon Sep 9, 2026
JS-3640
logaretm commentedon Sep 10, 2026
Thanks for the report, I managed to reproduce it and got a PR going for it.
fix(browser): Release the XHR `virtualError` once the request complet…