Is there an existing issue for this?
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 open http://localhost:8787/?sentry=on in 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 = undefined after the readyState === 4 handler ran.
- Create the
setRequestHeader Proxy in its own function, so it does not share a closure
context with virtualError.
- Keep only the formatted
stack string, or hold virtualError in a WeakRef.
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
Is there an existing issue for this?
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.initwith the default integrations, and runs four request chains. Each chain opens the nextXMLHttpRequestfrom thereadystatechangecallback of the previous one:This is a common pattern: polling loops, chunked uploads, chained REST calls, and hls.js Low-Latency HLS playlist
reloads.
Steps to Reproduce
node server.js, then openhttp://localhost:8787/?sentry=onin Chrome and press Start.XMLHttpRequest.http://localhost:8787/?sentry=off, press Start, and repeat. This is the control without the Sentrybundle. 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 liveXMLHttpRequestobjectsthrough CDP
Runtime.queryObjects.Expected Result
A completed
XMLHttpRequestbecomes 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:
The live
XMLHttpRequestcount equals the number of completed requests. Each retained request also pins itsresponseTextand everything the app's callback closures reference.Additional Context
What we think happens
In
packages/browser-utils/src/instrument/xhr.ts, theopenwrappercreates
const virtualError = new Error()for every request and never releases it. ThesetRequestHeaderProxy that the wrapper puts on the XHR instance lives in the samefunction scope, so it keeps
virtualErroralive for as long as the XHR is alive. Nobodyreads
virtualError.stack, so V8 keeps the raw stack frames, and each raw frame holds itsreceiver 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:
Related PR
#22216 (10.66.0) removes the
readystatechangelistener after the requestcompletes. That does not break this chain, because the
setRequestHeaderProxy still holdsthe context.
Possible fixes
Any one of these breaks the chain:
virtualError = undefinedafter thereadyState === 4handler ran.setRequestHeaderProxy in its own function, so it does not share a closurecontext with
virtualError.stackstring, or holdvirtualErrorin aWeakRef.Workaround
Turn off the XHR instrumentation. Both default integrations call
instrumentXHR, so both options are needed. This loses XHR breadcrumbs.Environment
Chrome 151.0.7922.176, macOS 26.6.2. Headless numbers from Chromium 1200
(Playwright 1.57) on macOS.
Priority
No response