Skip to content

Memory leak, instrumentXHR virtualError retains every XMLHttpRequest when requests are chained from readystatechange #24249

Description

@augustalfredsson

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

  1. Run node server.js, then open http://localhost:8787/?sentry=on in Chrome and press Start.
  2. 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.
  3. 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.
  4. 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

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions