fix(core): Add a PromiseBuffer for incoming events on the client - #18120

Merged
JPeer264 merged 4 commits into
developfrom
jp/memory-leak
Nov 20, 2025
Merged

fix(core): Add a PromiseBuffer for incoming events on the client#18120
JPeer264 merged 4 commits into
developfrom
jp/memory-leak

Conversation

@JPeer264

@JPeer264JPeer264 commented Nov 7, 2025

Copy link
Copy Markdown
Member

Problem

Previously, the client would process all incoming events without any limit, which could lead to unbounded growth of pending events/promises in memory. This could cause performance issues and memory pressure in high-throughput scenarios. This occurs when two conditions are met:

  • when an integration with an async processEvent are added (e.g. ContextLines, which is a defaultIntegration)
  • events, e.g. Sentry.captureException, are called synchronously
Sentry.init({ ... });// ...for(leti=0;i<5000;i++){Sentry.captureException(newError());}

Solution

This PR adds a PromiseBuffer to the Client class to limit the number of concurrent event processing operations.

  • Introduced a _promiseBuffer in the Client class that limits concurrent event processing
  • The buffer size defaults to DEFAULT_TRANSPORT_BUFFER_SIZE (64) but can be configured via transportOptions.bufferSize
  • When the buffer is full, events are rejected and properly tracked as dropped events with the queue_overflow reason
    • Please tak
  • Modified the _process() method to:
    • Accept a task producer function instead of a promise directly (lazy evaluation)
    • Use the promise buffer to manage concurrent operations
    • Track the data category for proper dropped event categorization

Special 👀 on

  • About reusing transportOptions.bufferSize: Not sure if this is the best technique, but IMO both should have the same size - because if it wouldn't it would be capped at a later stage (asking myself if the transport still needs the promise buffer - as we have it now way earlier in place)
  • The _process takes now a DataCategory. At the time of the process the event type is almost unknown. Not sure if I assumed the categories correctly there, or if there is another technique of getting the type (edit: a comment by Cursor helped a little and I added a helper function)
  • recordDroppedEvent is now printing it one after each other - theoretically we can count all occurences and print the count on it. I decided against this one, since it would delay the user feedback - this can be challenged though

@JPeer264
JPeer264force-pushed the jp/memory-leak branch 2 times, most recently from 619a016 to 1695dd4CompareNovember 7, 2025 15:43
processEvent(event: Event): Event | null | PromiseLike<Event | null> {
return new Promise(resolve => setTimeout(() => resolve(event), 1));
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Async Test Integration Missing Event Processor Setup

The AsyncTestIntegration defines a processEvent method but lacks a setupOnce or setup method to register it as an event processor. Without registration, the async processEvent won't execute, causing tests using this integration to pass incorrectly without actually exercising the promise buffer's async event handling logic.

Fix in CursorFix in Web

Comment threadpackages/core/src/client.ts Outdated
@github-actions

github-actionsBot commented Nov 7, 2025

Copy link
Copy Markdown
Contributor

size-limit report 📦

PathSize% ChangeChange
@sentry/browser24.7 kB+0.34%+82 B 🔺
@sentry/browser - with treeshaking flags23.2 kB+0.29%+66 B 🔺
@sentry/browser (incl. Tracing)41.43 kB+0.14%+55 B 🔺
@sentry/browser (incl. Tracing, Profiling)45.75 kB+0.15%+64 B 🔺
@sentry/browser (incl. Tracing, Replay)79.85 kB+0.04%+31 B 🔺
@sentry/browser (incl. Tracing, Replay) - with treeshaking flags69.57 kB+0.08%+49 B 🔺
@sentry/browser (incl. Tracing, Replay with Canvas)84.55 kB+0.06%+44 B 🔺
@sentry/browser (incl. Tracing, Replay, Feedback)96.77 kB+0.05%+45 B 🔺
@sentry/browser (incl. Feedback)41.38 kB+0.23%+94 B 🔺
@sentry/browser (incl. sendFeedback)29.39 kB+0.33%+94 B 🔺
@sentry/browser (incl. FeedbackAsync)34.33 kB+0.33%+111 B 🔺
@sentry/react26.41 kB+0.37%+97 B 🔺
@sentry/react (incl. Tracing)43.43 kB+0.26%+112 B 🔺
@sentry/vue29.15 kB+0.14%+38 B 🔺
@sentry/vue (incl. Tracing)43.23 kB+0.15%+64 B 🔺
@sentry/svelte24.72 kB+0.32%+78 B 🔺
CDN Bundle27.02 kB+0.24%+62 B 🔺
CDN Bundle (incl. Tracing)42.02 kB+0.17%+69 B 🔺
CDN Bundle (incl. Tracing, Replay)78.53 kB+0.05%+35 B 🔺
CDN Bundle (incl. Tracing, Replay, Feedback)84.02 kB+0.08%+62 B 🔺
CDN Bundle - uncompressed79.17 kB+0.29%+223 B 🔺
CDN Bundle (incl. Tracing) - uncompressed124.55 kB+0.18%+221 B 🔺
CDN Bundle (incl. Tracing, Replay) - uncompressed240.59 kB+0.1%+221 B 🔺
CDN Bundle (incl. Tracing, Replay, Feedback) - uncompressed253.35 kB+0.09%+221 B 🔺
@sentry/nextjs (client)45.84 kB+0.23%+104 B 🔺
@sentry/sveltekit (client)41.79 kB+0.07%+26 B 🔺
@sentry/node-core51.02 kB+0.14%+67 B 🔺
@sentry/node159.34 kB+0.05%+78 B 🔺
@sentry/node - without tracing92.9 kB+0.08%+70 B 🔺
@sentry/aws-serverless106.64 kB+0.07%+67 B 🔺

View base workflow run

@github-actions

github-actionsBot commented Nov 7, 2025

Copy link
Copy Markdown
Contributor

node-overhead report 🧳

Note: This is a synthetic benchmark with a minimal express app and does not necessarily reflect the real-world performance impact in an application.

ScenarioRequests/s% of BaselinePrev. Requests/sChange %
GET Baseline9,031-8,783+3%
GET With Sentry1,77720%1,354+31%
GET With Sentry (error only)6,27669%5,981+5%
POST Baseline1,220-1,200+2%
POST With Sentry59349%494+20%
POST With Sentry (error only)1,07388%1,036+4%
MYSQL Baseline3,399-3,258+4%
MYSQL With Sentry47614%464+3%
MYSQL With Sentry (error only)2,80182%2,673+5%

View base workflow run

@AbhiPrasadAbhiPrasad left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To me this feels a bit weird because it means we have two promise buffers in our pipeline, one to manage client processing state, and the other to manage the transport queue state.

The transport still needs a promise buffer to manage inflight requests because we need to make sure we don't saturate the user's network I/O.

I understand conceptually this is the best way to do it so giving it the ✅ (unless we totally overhaul _process, which honestly we might want to as a follow up).

We'll need the transport buffer size to be >= client buffer size, otherwise we risk backpressure issues. I think it's reasonable to assume the time it takes promises to resolve on the client buffer will be shorter than the transport buffer, so we could even make this size 32 instead of 64, or pick Math.ceil(transportOptions.bufferSize / 2)

It would be nice to just do a sanity check benchmark of the client buffer with a basic test app that we blast with load - we can use that to see if the buffer size assumptions hold up.

@JPeer264

Copy link
Copy Markdown
MemberAuthor

To me this feels a bit weird because it means we have two promise buffers in our pipeline, one to manage client processing state, and the other to manage the transport queue state.

To be fair I see this Promise buffer not as final solution, but more of mitigating the main issue. Overall there are quite some memory increases once we run into sync code

It would be nice to just do a sanity check benchmark of the client buffer with a basic test app that we blast with load - we can use that to see if the buffer size assumptions hold up.

So once we have code like above only 64 requests are taken and the rest are abandoned and removed so only 64 are going through - always, since the requests will be queued and not really processed further (unless we remove the async integrations). So in theory the second promise buffer doesn't do anything anymore in this scenario (that's just a guess at this point).

@AbhiPrasadAbhiPrasad left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So in theory the second promise buffer doesn't do anything anymore in this scenario (that's just a guess at this point).

the 2nd promise buffer tracks I/O instead of a transformed event, so I think it still matters.

Thinking about this more, the 2nd promise buffer probably resolves promises slower too, as it's based on request promises. It becomes the throughput bottleneck. If we size the first promise buffer to be too large, we will always drop events no matter what. So the size of the first promise buffer must be smaller than the second one.

Let's get this merged in and keep iterating.

@JPeer264
JPeer264 enabled auto-merge (squash) November 20, 2025 10:21
client.captureException(new Error('third'));

expect(client._clearOutcomes()).toEqual([]);
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Test expects wrong outcome for sync integrations

The test expects no dropped events when calling captureException three times with a buffer size of 1, but the promise buffer doesn't distinguish between sync and async integrations. With three synchronous calls and a buffer size of 1, the first call adds a promise to the buffer, and the second and third calls are rejected immediately because the buffer is full. The test should expect [{ reason: 'queue_overflow', category: 'error', quantity: 2 }] instead of an empty array.

Fix in CursorFix in Web

this._process(
() => promisedEvent.then(event => this._captureEvent(event, hintWithEventId, currentScope)),
isMessage ? 'unknown' : 'error',
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Promise created eagerly in captureMessage

In captureMessage, the promisedEvent is created outside the task producer function passed to _process. This means eventFromMessage or eventFromException is called immediately, even when the promise buffer is full. This defeats the lazy evaluation design of the promise buffer, causing unnecessary work when events should be dropped. The promise creation should be moved inside the task producer function to enable proper lazy evaluation.

Fix in CursorFix in Web

@JPeer264
JPeer264 merged commit c7e88d4 into developNov 20, 2025
379 of 383 checks passed
@JPeer264
JPeer264 deleted the jp/memory-leak branch November 20, 2025 16:43
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@JPeer264@AbhiPrasad
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

fix(core): Add a PromiseBuffer for incoming events on the client - #18120

Merged
JPeer264 merged 4 commits into
developfrom
jp/memory-leak
Nov 20, 2025
Merged

fix(core): Add a PromiseBuffer for incoming events on the client#18120
JPeer264 merged 4 commits into
developfrom
jp/memory-leak

Conversation

@JPeer264

@JPeer264JPeer264 commented Nov 7, 2025

Copy link
Copy Markdown
Member

Problem

Previously, the client would process all incoming events without any limit, which could lead to unbounded growth of pending events/promises in memory. This could cause performance issues and memory pressure in high-throughput scenarios. This occurs when two conditions are met:

  • when an integration with an async processEvent are added (e.g. ContextLines, which is a defaultIntegration)
  • events, e.g. Sentry.captureException, are called synchronously
Sentry.init({ ... });// ...for(leti=0;i<5000;i++){Sentry.captureException(newError());}

Solution

This PR adds a PromiseBuffer to the Client class to limit the number of concurrent event processing operations.

  • Introduced a _promiseBuffer in the Client class that limits concurrent event processing
  • The buffer size defaults to DEFAULT_TRANSPORT_BUFFER_SIZE (64) but can be configured via transportOptions.bufferSize
  • When the buffer is full, events are rejected and properly tracked as dropped events with the queue_overflow reason
    • Please tak
  • Modified the _process() method to:
    • Accept a task producer function instead of a promise directly (lazy evaluation)
    • Use the promise buffer to manage concurrent operations
    • Track the data category for proper dropped event categorization

Special 👀 on

  • About reusing transportOptions.bufferSize: Not sure if this is the best technique, but IMO both should have the same size - because if it wouldn't it would be capped at a later stage (asking myself if the transport still needs the promise buffer - as we have it now way earlier in place)
  • The _process takes now a DataCategory. At the time of the process the event type is almost unknown. Not sure if I assumed the categories correctly there, or if there is another technique of getting the type (edit: a comment by Cursor helped a little and I added a helper function)
  • recordDroppedEvent is now printing it one after each other - theoretically we can count all occurences and print the count on it. I decided against this one, since it would delay the user feedback - this can be challenged though

@JPeer264
JPeer264force-pushed the jp/memory-leak branch 2 times, most recently from 619a016 to 1695dd4CompareNovember 7, 2025 15:43
processEvent(event: Event): Event | null | PromiseLike<Event | null> {
return new Promise(resolve => setTimeout(() => resolve(event), 1));
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Async Test Integration Missing Event Processor Setup

The AsyncTestIntegration defines a processEvent method but lacks a setupOnce or setup method to register it as an event processor. Without registration, the async processEvent won't execute, causing tests using this integration to pass incorrectly without actually exercising the promise buffer's async event handling logic.

Fix in CursorFix in Web

Comment threadpackages/core/src/client.ts Outdated
@github-actions

github-actionsBot commented Nov 7, 2025

Copy link
Copy Markdown
Contributor

size-limit report 📦

PathSize% ChangeChange
@sentry/browser24.7 kB+0.34%+82 B 🔺
@sentry/browser - with treeshaking flags23.2 kB+0.29%+66 B 🔺
@sentry/browser (incl. Tracing)41.43 kB+0.14%+55 B 🔺
@sentry/browser (incl. Tracing, Profiling)45.75 kB+0.15%+64 B 🔺
@sentry/browser (incl. Tracing, Replay)79.85 kB+0.04%+31 B 🔺
@sentry/browser (incl. Tracing, Replay) - with treeshaking flags69.57 kB+0.08%+49 B 🔺
@sentry/browser (incl. Tracing, Replay with Canvas)84.55 kB+0.06%+44 B 🔺
@sentry/browser (incl. Tracing, Replay, Feedback)96.77 kB+0.05%+45 B 🔺
@sentry/browser (incl. Feedback)41.38 kB+0.23%+94 B 🔺
@sentry/browser (incl. sendFeedback)29.39 kB+0.33%+94 B 🔺
@sentry/browser (incl. FeedbackAsync)34.33 kB+0.33%+111 B 🔺
@sentry/react26.41 kB+0.37%+97 B 🔺
@sentry/react (incl. Tracing)43.43 kB+0.26%+112 B 🔺
@sentry/vue29.15 kB+0.14%+38 B 🔺
@sentry/vue (incl. Tracing)43.23 kB+0.15%+64 B 🔺
@sentry/svelte24.72 kB+0.32%+78 B 🔺
CDN Bundle27.02 kB+0.24%+62 B 🔺
CDN Bundle (incl. Tracing)42.02 kB+0.17%+69 B 🔺
CDN Bundle (incl. Tracing, Replay)78.53 kB+0.05%+35 B 🔺
CDN Bundle (incl. Tracing, Replay, Feedback)84.02 kB+0.08%+62 B 🔺
CDN Bundle - uncompressed79.17 kB+0.29%+223 B 🔺
CDN Bundle (incl. Tracing) - uncompressed124.55 kB+0.18%+221 B 🔺
CDN Bundle (incl. Tracing, Replay) - uncompressed240.59 kB+0.1%+221 B 🔺
CDN Bundle (incl. Tracing, Replay, Feedback) - uncompressed253.35 kB+0.09%+221 B 🔺
@sentry/nextjs (client)45.84 kB+0.23%+104 B 🔺
@sentry/sveltekit (client)41.79 kB+0.07%+26 B 🔺
@sentry/node-core51.02 kB+0.14%+67 B 🔺
@sentry/node159.34 kB+0.05%+78 B 🔺
@sentry/node - without tracing92.9 kB+0.08%+70 B 🔺
@sentry/aws-serverless106.64 kB+0.07%+67 B 🔺

View base workflow run

@github-actions

github-actionsBot commented Nov 7, 2025

Copy link
Copy Markdown
Contributor

node-overhead report 🧳

Note: This is a synthetic benchmark with a minimal express app and does not necessarily reflect the real-world performance impact in an application.

ScenarioRequests/s% of BaselinePrev. Requests/sChange %
GET Baseline9,031-8,783+3%
GET With Sentry1,77720%1,354+31%
GET With Sentry (error only)6,27669%5,981+5%
POST Baseline1,220-1,200+2%
POST With Sentry59349%494+20%
POST With Sentry (error only)1,07388%1,036+4%
MYSQL Baseline3,399-3,258+4%
MYSQL With Sentry47614%464+3%
MYSQL With Sentry (error only)2,80182%2,673+5%

View base workflow run

@AbhiPrasadAbhiPrasad left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To me this feels a bit weird because it means we have two promise buffers in our pipeline, one to manage client processing state, and the other to manage the transport queue state.

The transport still needs a promise buffer to manage inflight requests because we need to make sure we don't saturate the user's network I/O.

I understand conceptually this is the best way to do it so giving it the ✅ (unless we totally overhaul _process, which honestly we might want to as a follow up).

We'll need the transport buffer size to be >= client buffer size, otherwise we risk backpressure issues. I think it's reasonable to assume the time it takes promises to resolve on the client buffer will be shorter than the transport buffer, so we could even make this size 32 instead of 64, or pick Math.ceil(transportOptions.bufferSize / 2)

It would be nice to just do a sanity check benchmark of the client buffer with a basic test app that we blast with load - we can use that to see if the buffer size assumptions hold up.

@JPeer264

Copy link
Copy Markdown
MemberAuthor

To me this feels a bit weird because it means we have two promise buffers in our pipeline, one to manage client processing state, and the other to manage the transport queue state.

To be fair I see this Promise buffer not as final solution, but more of mitigating the main issue. Overall there are quite some memory increases once we run into sync code

It would be nice to just do a sanity check benchmark of the client buffer with a basic test app that we blast with load - we can use that to see if the buffer size assumptions hold up.

So once we have code like above only 64 requests are taken and the rest are abandoned and removed so only 64 are going through - always, since the requests will be queued and not really processed further (unless we remove the async integrations). So in theory the second promise buffer doesn't do anything anymore in this scenario (that's just a guess at this point).

@AbhiPrasadAbhiPrasad left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So in theory the second promise buffer doesn't do anything anymore in this scenario (that's just a guess at this point).

the 2nd promise buffer tracks I/O instead of a transformed event, so I think it still matters.

Thinking about this more, the 2nd promise buffer probably resolves promises slower too, as it's based on request promises. It becomes the throughput bottleneck. If we size the first promise buffer to be too large, we will always drop events no matter what. So the size of the first promise buffer must be smaller than the second one.

Let's get this merged in and keep iterating.

@JPeer264
JPeer264 enabled auto-merge (squash) November 20, 2025 10:21
client.captureException(new Error('third'));

expect(client._clearOutcomes()).toEqual([]);
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Test expects wrong outcome for sync integrations

The test expects no dropped events when calling captureException three times with a buffer size of 1, but the promise buffer doesn't distinguish between sync and async integrations. With three synchronous calls and a buffer size of 1, the first call adds a promise to the buffer, and the second and third calls are rejected immediately because the buffer is full. The test should expect [{ reason: 'queue_overflow', category: 'error', quantity: 2 }] instead of an empty array.

Fix in CursorFix in Web

this._process(
() => promisedEvent.then(event => this._captureEvent(event, hintWithEventId, currentScope)),
isMessage ? 'unknown' : 'error',
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Promise created eagerly in captureMessage

In captureMessage, the promisedEvent is created outside the task producer function passed to _process. This means eventFromMessage or eventFromException is called immediately, even when the promise buffer is full. This defeats the lazy evaluation design of the promise buffer, causing unnecessary work when events should be dropped. The promise creation should be moved inside the task producer function to enable proper lazy evaluation.

Fix in CursorFix in Web

@JPeer264
JPeer264 merged commit c7e88d4 into developNov 20, 2025
379 of 383 checks passed
@JPeer264
JPeer264 deleted the jp/memory-leak branch November 20, 2025 16:43
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@JPeer264@AbhiPrasad
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(core): Add a PromiseBuffer for incoming events on the client - #18120

Merged
JPeer264 merged 4 commits into
developfrom
jp/memory-leak
Nov 20, 2025
Merged

fix(core): Add a PromiseBuffer for incoming events on the client#18120
JPeer264 merged 4 commits into
developfrom
jp/memory-leak

Conversation

@JPeer264

@JPeer264JPeer264 commented Nov 7, 2025

Copy link
Copy Markdown
Member

Problem

Previously, the client would process all incoming events without any limit, which could lead to unbounded growth of pending events/promises in memory. This could cause performance issues and memory pressure in high-throughput scenarios. This occurs when two conditions are met:

  • when an integration with an async processEvent are added (e.g. ContextLines, which is a defaultIntegration)
  • events, e.g. Sentry.captureException, are called synchronously
Sentry.init({ ... });// ...for(leti=0;i<5000;i++){Sentry.captureException(newError());}

Solution

This PR adds a PromiseBuffer to the Client class to limit the number of concurrent event processing operations.

  • Introduced a _promiseBuffer in the Client class that limits concurrent event processing
  • The buffer size defaults to DEFAULT_TRANSPORT_BUFFER_SIZE (64) but can be configured via transportOptions.bufferSize
  • When the buffer is full, events are rejected and properly tracked as dropped events with the queue_overflow reason
    • Please tak
  • Modified the _process() method to:
    • Accept a task producer function instead of a promise directly (lazy evaluation)
    • Use the promise buffer to manage concurrent operations
    • Track the data category for proper dropped event categorization

Special 👀 on

  • About reusing transportOptions.bufferSize: Not sure if this is the best technique, but IMO both should have the same size - because if it wouldn't it would be capped at a later stage (asking myself if the transport still needs the promise buffer - as we have it now way earlier in place)
  • The _process takes now a DataCategory. At the time of the process the event type is almost unknown. Not sure if I assumed the categories correctly there, or if there is another technique of getting the type (edit: a comment by Cursor helped a little and I added a helper function)
  • recordDroppedEvent is now printing it one after each other - theoretically we can count all occurences and print the count on it. I decided against this one, since it would delay the user feedback - this can be challenged though

@JPeer264
JPeer264force-pushed the jp/memory-leak branch 2 times, most recently from 619a016 to 1695dd4CompareNovember 7, 2025 15:43
processEvent(event: Event): Event | null | PromiseLike<Event | null> {
return new Promise(resolve => setTimeout(() => resolve(event), 1));
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Async Test Integration Missing Event Processor Setup

The AsyncTestIntegration defines a processEvent method but lacks a setupOnce or setup method to register it as an event processor. Without registration, the async processEvent won't execute, causing tests using this integration to pass incorrectly without actually exercising the promise buffer's async event handling logic.

Fix in CursorFix in Web

Comment threadpackages/core/src/client.ts Outdated
@github-actions

github-actionsBot commented Nov 7, 2025

Copy link
Copy Markdown
Contributor

size-limit report 📦

PathSize% ChangeChange
@sentry/browser24.7 kB+0.34%+82 B 🔺
@sentry/browser - with treeshaking flags23.2 kB+0.29%+66 B 🔺
@sentry/browser (incl. Tracing)41.43 kB+0.14%+55 B 🔺
@sentry/browser (incl. Tracing, Profiling)45.75 kB+0.15%+64 B 🔺
@sentry/browser (incl. Tracing, Replay)79.85 kB+0.04%+31 B 🔺
@sentry/browser (incl. Tracing, Replay) - with treeshaking flags69.57 kB+0.08%+49 B 🔺
@sentry/browser (incl. Tracing, Replay with Canvas)84.55 kB+0.06%+44 B 🔺
@sentry/browser (incl. Tracing, Replay, Feedback)96.77 kB+0.05%+45 B 🔺
@sentry/browser (incl. Feedback)41.38 kB+0.23%+94 B 🔺
@sentry/browser (incl. sendFeedback)29.39 kB+0.33%+94 B 🔺
@sentry/browser (incl. FeedbackAsync)34.33 kB+0.33%+111 B 🔺
@sentry/react26.41 kB+0.37%+97 B 🔺
@sentry/react (incl. Tracing)43.43 kB+0.26%+112 B 🔺
@sentry/vue29.15 kB+0.14%+38 B 🔺
@sentry/vue (incl. Tracing)43.23 kB+0.15%+64 B 🔺
@sentry/svelte24.72 kB+0.32%+78 B 🔺
CDN Bundle27.02 kB+0.24%+62 B 🔺
CDN Bundle (incl. Tracing)42.02 kB+0.17%+69 B 🔺
CDN Bundle (incl. Tracing, Replay)78.53 kB+0.05%+35 B 🔺
CDN Bundle (incl. Tracing, Replay, Feedback)84.02 kB+0.08%+62 B 🔺
CDN Bundle - uncompressed79.17 kB+0.29%+223 B 🔺
CDN Bundle (incl. Tracing) - uncompressed124.55 kB+0.18%+221 B 🔺
CDN Bundle (incl. Tracing, Replay) - uncompressed240.59 kB+0.1%+221 B 🔺
CDN Bundle (incl. Tracing, Replay, Feedback) - uncompressed253.35 kB+0.09%+221 B 🔺
@sentry/nextjs (client)45.84 kB+0.23%+104 B 🔺
@sentry/sveltekit (client)41.79 kB+0.07%+26 B 🔺
@sentry/node-core51.02 kB+0.14%+67 B 🔺
@sentry/node159.34 kB+0.05%+78 B 🔺
@sentry/node - without tracing92.9 kB+0.08%+70 B 🔺
@sentry/aws-serverless106.64 kB+0.07%+67 B 🔺

View base workflow run

@github-actions

github-actionsBot commented Nov 7, 2025

Copy link
Copy Markdown
Contributor

node-overhead report 🧳

Note: This is a synthetic benchmark with a minimal express app and does not necessarily reflect the real-world performance impact in an application.

ScenarioRequests/s% of BaselinePrev. Requests/sChange %
GET Baseline9,031-8,783+3%
GET With Sentry1,77720%1,354+31%
GET With Sentry (error only)6,27669%5,981+5%
POST Baseline1,220-1,200+2%
POST With Sentry59349%494+20%
POST With Sentry (error only)1,07388%1,036+4%
MYSQL Baseline3,399-3,258+4%
MYSQL With Sentry47614%464+3%
MYSQL With Sentry (error only)2,80182%2,673+5%

View base workflow run

@AbhiPrasadAbhiPrasad left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To me this feels a bit weird because it means we have two promise buffers in our pipeline, one to manage client processing state, and the other to manage the transport queue state.

The transport still needs a promise buffer to manage inflight requests because we need to make sure we don't saturate the user's network I/O.

I understand conceptually this is the best way to do it so giving it the ✅ (unless we totally overhaul _process, which honestly we might want to as a follow up).

We'll need the transport buffer size to be >= client buffer size, otherwise we risk backpressure issues. I think it's reasonable to assume the time it takes promises to resolve on the client buffer will be shorter than the transport buffer, so we could even make this size 32 instead of 64, or pick Math.ceil(transportOptions.bufferSize / 2)

It would be nice to just do a sanity check benchmark of the client buffer with a basic test app that we blast with load - we can use that to see if the buffer size assumptions hold up.

@JPeer264

Copy link
Copy Markdown
MemberAuthor

To me this feels a bit weird because it means we have two promise buffers in our pipeline, one to manage client processing state, and the other to manage the transport queue state.

To be fair I see this Promise buffer not as final solution, but more of mitigating the main issue. Overall there are quite some memory increases once we run into sync code

It would be nice to just do a sanity check benchmark of the client buffer with a basic test app that we blast with load - we can use that to see if the buffer size assumptions hold up.

So once we have code like above only 64 requests are taken and the rest are abandoned and removed so only 64 are going through - always, since the requests will be queued and not really processed further (unless we remove the async integrations). So in theory the second promise buffer doesn't do anything anymore in this scenario (that's just a guess at this point).

@AbhiPrasadAbhiPrasad left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So in theory the second promise buffer doesn't do anything anymore in this scenario (that's just a guess at this point).

the 2nd promise buffer tracks I/O instead of a transformed event, so I think it still matters.

Thinking about this more, the 2nd promise buffer probably resolves promises slower too, as it's based on request promises. It becomes the throughput bottleneck. If we size the first promise buffer to be too large, we will always drop events no matter what. So the size of the first promise buffer must be smaller than the second one.

Let's get this merged in and keep iterating.

@JPeer264
JPeer264 enabled auto-merge (squash) November 20, 2025 10:21
client.captureException(new Error('third'));

expect(client._clearOutcomes()).toEqual([]);
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Test expects wrong outcome for sync integrations

The test expects no dropped events when calling captureException three times with a buffer size of 1, but the promise buffer doesn't distinguish between sync and async integrations. With three synchronous calls and a buffer size of 1, the first call adds a promise to the buffer, and the second and third calls are rejected immediately because the buffer is full. The test should expect [{ reason: 'queue_overflow', category: 'error', quantity: 2 }] instead of an empty array.

Fix in CursorFix in Web

this._process(
() => promisedEvent.then(event => this._captureEvent(event, hintWithEventId, currentScope)),
isMessage ? 'unknown' : 'error',
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Promise created eagerly in captureMessage

In captureMessage, the promisedEvent is created outside the task producer function passed to _process. This means eventFromMessage or eventFromException is called immediately, even when the promise buffer is full. This defeats the lazy evaluation design of the promise buffer, causing unnecessary work when events should be dropped. The promise creation should be moved inside the task producer function to enable proper lazy evaluation.

Fix in CursorFix in Web

@JPeer264
JPeer264 merged commit c7e88d4 into developNov 20, 2025
379 of 383 checks passed
@JPeer264
JPeer264 deleted the jp/memory-leak branch November 20, 2025 16:43
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@JPeer264@AbhiPrasad
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(core): Add a PromiseBuffer for incoming events on the client - #18120

Merged
JPeer264 merged 4 commits into
developfrom
jp/memory-leak
Nov 20, 2025
Merged

fix(core): Add a PromiseBuffer for incoming events on the client#18120
JPeer264 merged 4 commits into
developfrom
jp/memory-leak

Conversation

@JPeer264

@JPeer264JPeer264 commented Nov 7, 2025

Copy link
Copy Markdown
Member

Problem

Previously, the client would process all incoming events without any limit, which could lead to unbounded growth of pending events/promises in memory. This could cause performance issues and memory pressure in high-throughput scenarios. This occurs when two conditions are met:

  • when an integration with an async processEvent are added (e.g. ContextLines, which is a defaultIntegration)
  • events, e.g. Sentry.captureException, are called synchronously
Sentry.init({ ... });// ...for(leti=0;i<5000;i++){Sentry.captureException(newError());}

Solution

This PR adds a PromiseBuffer to the Client class to limit the number of concurrent event processing operations.

  • Introduced a _promiseBuffer in the Client class that limits concurrent event processing
  • The buffer size defaults to DEFAULT_TRANSPORT_BUFFER_SIZE (64) but can be configured via transportOptions.bufferSize
  • When the buffer is full, events are rejected and properly tracked as dropped events with the queue_overflow reason
    • Please tak
  • Modified the _process() method to:
    • Accept a task producer function instead of a promise directly (lazy evaluation)
    • Use the promise buffer to manage concurrent operations
    • Track the data category for proper dropped event categorization

Special 👀 on

  • About reusing transportOptions.bufferSize: Not sure if this is the best technique, but IMO both should have the same size - because if it wouldn't it would be capped at a later stage (asking myself if the transport still needs the promise buffer - as we have it now way earlier in place)
  • The _process takes now a DataCategory. At the time of the process the event type is almost unknown. Not sure if I assumed the categories correctly there, or if there is another technique of getting the type (edit: a comment by Cursor helped a little and I added a helper function)
  • recordDroppedEvent is now printing it one after each other - theoretically we can count all occurences and print the count on it. I decided against this one, since it would delay the user feedback - this can be challenged though

@JPeer264
JPeer264force-pushed the jp/memory-leak branch 2 times, most recently from 619a016 to 1695dd4CompareNovember 7, 2025 15:43
processEvent(event: Event): Event | null | PromiseLike<Event | null> {
return new Promise(resolve => setTimeout(() => resolve(event), 1));
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Async Test Integration Missing Event Processor Setup

The AsyncTestIntegration defines a processEvent method but lacks a setupOnce or setup method to register it as an event processor. Without registration, the async processEvent won't execute, causing tests using this integration to pass incorrectly without actually exercising the promise buffer's async event handling logic.

Fix in CursorFix in Web

Comment threadpackages/core/src/client.ts Outdated
@github-actions

github-actionsBot commented Nov 7, 2025

Copy link
Copy Markdown
Contributor

size-limit report 📦

PathSize% ChangeChange
@sentry/browser24.7 kB+0.34%+82 B 🔺
@sentry/browser - with treeshaking flags23.2 kB+0.29%+66 B 🔺
@sentry/browser (incl. Tracing)41.43 kB+0.14%+55 B 🔺
@sentry/browser (incl. Tracing, Profiling)45.75 kB+0.15%+64 B 🔺
@sentry/browser (incl. Tracing, Replay)79.85 kB+0.04%+31 B 🔺
@sentry/browser (incl. Tracing, Replay) - with treeshaking flags69.57 kB+0.08%+49 B 🔺
@sentry/browser (incl. Tracing, Replay with Canvas)84.55 kB+0.06%+44 B 🔺
@sentry/browser (incl. Tracing, Replay, Feedback)96.77 kB+0.05%+45 B 🔺
@sentry/browser (incl. Feedback)41.38 kB+0.23%+94 B 🔺
@sentry/browser (incl. sendFeedback)29.39 kB+0.33%+94 B 🔺
@sentry/browser (incl. FeedbackAsync)34.33 kB+0.33%+111 B 🔺
@sentry/react26.41 kB+0.37%+97 B 🔺
@sentry/react (incl. Tracing)43.43 kB+0.26%+112 B 🔺
@sentry/vue29.15 kB+0.14%+38 B 🔺
@sentry/vue (incl. Tracing)43.23 kB+0.15%+64 B 🔺
@sentry/svelte24.72 kB+0.32%+78 B 🔺
CDN Bundle27.02 kB+0.24%+62 B 🔺
CDN Bundle (incl. Tracing)42.02 kB+0.17%+69 B 🔺
CDN Bundle (incl. Tracing, Replay)78.53 kB+0.05%+35 B 🔺
CDN Bundle (incl. Tracing, Replay, Feedback)84.02 kB+0.08%+62 B 🔺
CDN Bundle - uncompressed79.17 kB+0.29%+223 B 🔺
CDN Bundle (incl. Tracing) - uncompressed124.55 kB+0.18%+221 B 🔺
CDN Bundle (incl. Tracing, Replay) - uncompressed240.59 kB+0.1%+221 B 🔺
CDN Bundle (incl. Tracing, Replay, Feedback) - uncompressed253.35 kB+0.09%+221 B 🔺
@sentry/nextjs (client)45.84 kB+0.23%+104 B 🔺
@sentry/sveltekit (client)41.79 kB+0.07%+26 B 🔺
@sentry/node-core51.02 kB+0.14%+67 B 🔺
@sentry/node159.34 kB+0.05%+78 B 🔺
@sentry/node - without tracing92.9 kB+0.08%+70 B 🔺
@sentry/aws-serverless106.64 kB+0.07%+67 B 🔺

View base workflow run

@github-actions

github-actionsBot commented Nov 7, 2025

Copy link
Copy Markdown
Contributor

node-overhead report 🧳

Note: This is a synthetic benchmark with a minimal express app and does not necessarily reflect the real-world performance impact in an application.

ScenarioRequests/s% of BaselinePrev. Requests/sChange %
GET Baseline9,031-8,783+3%
GET With Sentry1,77720%1,354+31%
GET With Sentry (error only)6,27669%5,981+5%
POST Baseline1,220-1,200+2%
POST With Sentry59349%494+20%
POST With Sentry (error only)1,07388%1,036+4%
MYSQL Baseline3,399-3,258+4%
MYSQL With Sentry47614%464+3%
MYSQL With Sentry (error only)2,80182%2,673+5%

View base workflow run

@AbhiPrasadAbhiPrasad left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To me this feels a bit weird because it means we have two promise buffers in our pipeline, one to manage client processing state, and the other to manage the transport queue state.

The transport still needs a promise buffer to manage inflight requests because we need to make sure we don't saturate the user's network I/O.

I understand conceptually this is the best way to do it so giving it the ✅ (unless we totally overhaul _process, which honestly we might want to as a follow up).

We'll need the transport buffer size to be >= client buffer size, otherwise we risk backpressure issues. I think it's reasonable to assume the time it takes promises to resolve on the client buffer will be shorter than the transport buffer, so we could even make this size 32 instead of 64, or pick Math.ceil(transportOptions.bufferSize / 2)

It would be nice to just do a sanity check benchmark of the client buffer with a basic test app that we blast with load - we can use that to see if the buffer size assumptions hold up.

@JPeer264

Copy link
Copy Markdown
MemberAuthor

To me this feels a bit weird because it means we have two promise buffers in our pipeline, one to manage client processing state, and the other to manage the transport queue state.

To be fair I see this Promise buffer not as final solution, but more of mitigating the main issue. Overall there are quite some memory increases once we run into sync code

It would be nice to just do a sanity check benchmark of the client buffer with a basic test app that we blast with load - we can use that to see if the buffer size assumptions hold up.

So once we have code like above only 64 requests are taken and the rest are abandoned and removed so only 64 are going through - always, since the requests will be queued and not really processed further (unless we remove the async integrations). So in theory the second promise buffer doesn't do anything anymore in this scenario (that's just a guess at this point).

@AbhiPrasadAbhiPrasad left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So in theory the second promise buffer doesn't do anything anymore in this scenario (that's just a guess at this point).

the 2nd promise buffer tracks I/O instead of a transformed event, so I think it still matters.

Thinking about this more, the 2nd promise buffer probably resolves promises slower too, as it's based on request promises. It becomes the throughput bottleneck. If we size the first promise buffer to be too large, we will always drop events no matter what. So the size of the first promise buffer must be smaller than the second one.

Let's get this merged in and keep iterating.

@JPeer264
JPeer264 enabled auto-merge (squash) November 20, 2025 10:21
client.captureException(new Error('third'));

expect(client._clearOutcomes()).toEqual([]);
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Test expects wrong outcome for sync integrations

The test expects no dropped events when calling captureException three times with a buffer size of 1, but the promise buffer doesn't distinguish between sync and async integrations. With three synchronous calls and a buffer size of 1, the first call adds a promise to the buffer, and the second and third calls are rejected immediately because the buffer is full. The test should expect [{ reason: 'queue_overflow', category: 'error', quantity: 2 }] instead of an empty array.

Fix in CursorFix in Web

this._process(
() => promisedEvent.then(event => this._captureEvent(event, hintWithEventId, currentScope)),
isMessage ? 'unknown' : 'error',
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Promise created eagerly in captureMessage

In captureMessage, the promisedEvent is created outside the task producer function passed to _process. This means eventFromMessage or eventFromException is called immediately, even when the promise buffer is full. This defeats the lazy evaluation design of the promise buffer, causing unnecessary work when events should be dropped. The promise creation should be moved inside the task producer function to enable proper lazy evaluation.

Fix in CursorFix in Web

@JPeer264
JPeer264 merged commit c7e88d4 into developNov 20, 2025
379 of 383 checks passed
@JPeer264
JPeer264 deleted the jp/memory-leak branch November 20, 2025 16:43
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@JPeer264@AbhiPrasad
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

fix(core): Add a PromiseBuffer for incoming events on the client - #18120

Merged
JPeer264 merged 4 commits into
developfrom
jp/memory-leak
Nov 20, 2025
Merged

fix(core): Add a PromiseBuffer for incoming events on the client#18120
JPeer264 merged 4 commits into
developfrom
jp/memory-leak

Conversation

@JPeer264

@JPeer264JPeer264 commented Nov 7, 2025

Copy link
Copy Markdown
Member

Problem

Previously, the client would process all incoming events without any limit, which could lead to unbounded growth of pending events/promises in memory. This could cause performance issues and memory pressure in high-throughput scenarios. This occurs when two conditions are met:

  • when an integration with an async processEvent are added (e.g. ContextLines, which is a defaultIntegration)
  • events, e.g. Sentry.captureException, are called synchronously
Sentry.init({ ... });// ...for(leti=0;i<5000;i++){Sentry.captureException(newError());}

Solution

This PR adds a PromiseBuffer to the Client class to limit the number of concurrent event processing operations.

  • Introduced a _promiseBuffer in the Client class that limits concurrent event processing
  • The buffer size defaults to DEFAULT_TRANSPORT_BUFFER_SIZE (64) but can be configured via transportOptions.bufferSize
  • When the buffer is full, events are rejected and properly tracked as dropped events with the queue_overflow reason
    • Please tak
  • Modified the _process() method to:
    • Accept a task producer function instead of a promise directly (lazy evaluation)
    • Use the promise buffer to manage concurrent operations
    • Track the data category for proper dropped event categorization

Special 👀 on

  • About reusing transportOptions.bufferSize: Not sure if this is the best technique, but IMO both should have the same size - because if it wouldn't it would be capped at a later stage (asking myself if the transport still needs the promise buffer - as we have it now way earlier in place)
  • The _process takes now a DataCategory. At the time of the process the event type is almost unknown. Not sure if I assumed the categories correctly there, or if there is another technique of getting the type (edit: a comment by Cursor helped a little and I added a helper function)
  • recordDroppedEvent is now printing it one after each other - theoretically we can count all occurences and print the count on it. I decided against this one, since it would delay the user feedback - this can be challenged though

@JPeer264
JPeer264force-pushed the jp/memory-leak branch 2 times, most recently from 619a016 to 1695dd4CompareNovember 7, 2025 15:43
processEvent(event: Event): Event | null | PromiseLike<Event | null> {
return new Promise(resolve => setTimeout(() => resolve(event), 1));
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Async Test Integration Missing Event Processor Setup

The AsyncTestIntegration defines a processEvent method but lacks a setupOnce or setup method to register it as an event processor. Without registration, the async processEvent won't execute, causing tests using this integration to pass incorrectly without actually exercising the promise buffer's async event handling logic.

Fix in CursorFix in Web

Comment threadpackages/core/src/client.ts Outdated
@github-actions

github-actionsBot commented Nov 7, 2025

Copy link
Copy Markdown
Contributor

size-limit report 📦

PathSize% ChangeChange
@sentry/browser24.7 kB+0.34%+82 B 🔺
@sentry/browser - with treeshaking flags23.2 kB+0.29%+66 B 🔺
@sentry/browser (incl. Tracing)41.43 kB+0.14%+55 B 🔺
@sentry/browser (incl. Tracing, Profiling)45.75 kB+0.15%+64 B 🔺
@sentry/browser (incl. Tracing, Replay)79.85 kB+0.04%+31 B 🔺
@sentry/browser (incl. Tracing, Replay) - with treeshaking flags69.57 kB+0.08%+49 B 🔺
@sentry/browser (incl. Tracing, Replay with Canvas)84.55 kB+0.06%+44 B 🔺
@sentry/browser (incl. Tracing, Replay, Feedback)96.77 kB+0.05%+45 B 🔺
@sentry/browser (incl. Feedback)41.38 kB+0.23%+94 B 🔺
@sentry/browser (incl. sendFeedback)29.39 kB+0.33%+94 B 🔺
@sentry/browser (incl. FeedbackAsync)34.33 kB+0.33%+111 B 🔺
@sentry/react26.41 kB+0.37%+97 B 🔺
@sentry/react (incl. Tracing)43.43 kB+0.26%+112 B 🔺
@sentry/vue29.15 kB+0.14%+38 B 🔺
@sentry/vue (incl. Tracing)43.23 kB+0.15%+64 B 🔺
@sentry/svelte24.72 kB+0.32%+78 B 🔺
CDN Bundle27.02 kB+0.24%+62 B 🔺
CDN Bundle (incl. Tracing)42.02 kB+0.17%+69 B 🔺
CDN Bundle (incl. Tracing, Replay)78.53 kB+0.05%+35 B 🔺
CDN Bundle (incl. Tracing, Replay, Feedback)84.02 kB+0.08%+62 B 🔺
CDN Bundle - uncompressed79.17 kB+0.29%+223 B 🔺
CDN Bundle (incl. Tracing) - uncompressed124.55 kB+0.18%+221 B 🔺
CDN Bundle (incl. Tracing, Replay) - uncompressed240.59 kB+0.1%+221 B 🔺
CDN Bundle (incl. Tracing, Replay, Feedback) - uncompressed253.35 kB+0.09%+221 B 🔺
@sentry/nextjs (client)45.84 kB+0.23%+104 B 🔺
@sentry/sveltekit (client)41.79 kB+0.07%+26 B 🔺
@sentry/node-core51.02 kB+0.14%+67 B 🔺
@sentry/node159.34 kB+0.05%+78 B 🔺
@sentry/node - without tracing92.9 kB+0.08%+70 B 🔺
@sentry/aws-serverless106.64 kB+0.07%+67 B 🔺

View base workflow run

@github-actions

github-actionsBot commented Nov 7, 2025

Copy link
Copy Markdown
Contributor

node-overhead report 🧳

Note: This is a synthetic benchmark with a minimal express app and does not necessarily reflect the real-world performance impact in an application.

ScenarioRequests/s% of BaselinePrev. Requests/sChange %
GET Baseline9,031-8,783+3%
GET With Sentry1,77720%1,354+31%
GET With Sentry (error only)6,27669%5,981+5%
POST Baseline1,220-1,200+2%
POST With Sentry59349%494+20%
POST With Sentry (error only)1,07388%1,036+4%
MYSQL Baseline3,399-3,258+4%
MYSQL With Sentry47614%464+3%
MYSQL With Sentry (error only)2,80182%2,673+5%

View base workflow run

@AbhiPrasadAbhiPrasad left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To me this feels a bit weird because it means we have two promise buffers in our pipeline, one to manage client processing state, and the other to manage the transport queue state.

The transport still needs a promise buffer to manage inflight requests because we need to make sure we don't saturate the user's network I/O.

I understand conceptually this is the best way to do it so giving it the ✅ (unless we totally overhaul _process, which honestly we might want to as a follow up).

We'll need the transport buffer size to be >= client buffer size, otherwise we risk backpressure issues. I think it's reasonable to assume the time it takes promises to resolve on the client buffer will be shorter than the transport buffer, so we could even make this size 32 instead of 64, or pick Math.ceil(transportOptions.bufferSize / 2)

It would be nice to just do a sanity check benchmark of the client buffer with a basic test app that we blast with load - we can use that to see if the buffer size assumptions hold up.

@JPeer264

Copy link
Copy Markdown
MemberAuthor

To me this feels a bit weird because it means we have two promise buffers in our pipeline, one to manage client processing state, and the other to manage the transport queue state.

To be fair I see this Promise buffer not as final solution, but more of mitigating the main issue. Overall there are quite some memory increases once we run into sync code

It would be nice to just do a sanity check benchmark of the client buffer with a basic test app that we blast with load - we can use that to see if the buffer size assumptions hold up.

So once we have code like above only 64 requests are taken and the rest are abandoned and removed so only 64 are going through - always, since the requests will be queued and not really processed further (unless we remove the async integrations). So in theory the second promise buffer doesn't do anything anymore in this scenario (that's just a guess at this point).

@AbhiPrasadAbhiPrasad left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So in theory the second promise buffer doesn't do anything anymore in this scenario (that's just a guess at this point).

the 2nd promise buffer tracks I/O instead of a transformed event, so I think it still matters.

Thinking about this more, the 2nd promise buffer probably resolves promises slower too, as it's based on request promises. It becomes the throughput bottleneck. If we size the first promise buffer to be too large, we will always drop events no matter what. So the size of the first promise buffer must be smaller than the second one.

Let's get this merged in and keep iterating.

@JPeer264
JPeer264 enabled auto-merge (squash) November 20, 2025 10:21
client.captureException(new Error('third'));

expect(client._clearOutcomes()).toEqual([]);
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Test expects wrong outcome for sync integrations

The test expects no dropped events when calling captureException three times with a buffer size of 1, but the promise buffer doesn't distinguish between sync and async integrations. With three synchronous calls and a buffer size of 1, the first call adds a promise to the buffer, and the second and third calls are rejected immediately because the buffer is full. The test should expect [{ reason: 'queue_overflow', category: 'error', quantity: 2 }] instead of an empty array.

Fix in CursorFix in Web

this._process(
() => promisedEvent.then(event => this._captureEvent(event, hintWithEventId, currentScope)),
isMessage ? 'unknown' : 'error',
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Promise created eagerly in captureMessage

In captureMessage, the promisedEvent is created outside the task producer function passed to _process. This means eventFromMessage or eventFromException is called immediately, even when the promise buffer is full. This defeats the lazy evaluation design of the promise buffer, causing unnecessary work when events should be dropped. The promise creation should be moved inside the task producer function to enable proper lazy evaluation.

Fix in CursorFix in Web

@JPeer264
JPeer264 merged commit c7e88d4 into developNov 20, 2025
379 of 383 checks passed
@JPeer264
JPeer264 deleted the jp/memory-leak branch November 20, 2025 16:43
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@JPeer264@AbhiPrasad
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(core): Add a PromiseBuffer for incoming events on the client - #18120

Merged
JPeer264 merged 4 commits into
developfrom
jp/memory-leak
Nov 20, 2025
Merged

fix(core): Add a PromiseBuffer for incoming events on the client#18120
JPeer264 merged 4 commits into
developfrom
jp/memory-leak

Conversation

@JPeer264

@JPeer264JPeer264 commented Nov 7, 2025

Copy link
Copy Markdown
Member

Problem

Previously, the client would process all incoming events without any limit, which could lead to unbounded growth of pending events/promises in memory. This could cause performance issues and memory pressure in high-throughput scenarios. This occurs when two conditions are met:

  • when an integration with an async processEvent are added (e.g. ContextLines, which is a defaultIntegration)
  • events, e.g. Sentry.captureException, are called synchronously
Sentry.init({ ... });// ...for(leti=0;i<5000;i++){Sentry.captureException(newError());}

Solution

This PR adds a PromiseBuffer to the Client class to limit the number of concurrent event processing operations.

  • Introduced a _promiseBuffer in the Client class that limits concurrent event processing
  • The buffer size defaults to DEFAULT_TRANSPORT_BUFFER_SIZE (64) but can be configured via transportOptions.bufferSize
  • When the buffer is full, events are rejected and properly tracked as dropped events with the queue_overflow reason
    • Please tak
  • Modified the _process() method to:
    • Accept a task producer function instead of a promise directly (lazy evaluation)
    • Use the promise buffer to manage concurrent operations
    • Track the data category for proper dropped event categorization

Special 👀 on

  • About reusing transportOptions.bufferSize: Not sure if this is the best technique, but IMO both should have the same size - because if it wouldn't it would be capped at a later stage (asking myself if the transport still needs the promise buffer - as we have it now way earlier in place)
  • The _process takes now a DataCategory. At the time of the process the event type is almost unknown. Not sure if I assumed the categories correctly there, or if there is another technique of getting the type (edit: a comment by Cursor helped a little and I added a helper function)
  • recordDroppedEvent is now printing it one after each other - theoretically we can count all occurences and print the count on it. I decided against this one, since it would delay the user feedback - this can be challenged though

@JPeer264
JPeer264force-pushed the jp/memory-leak branch 2 times, most recently from 619a016 to 1695dd4CompareNovember 7, 2025 15:43
processEvent(event: Event): Event | null | PromiseLike<Event | null> {
return new Promise(resolve => setTimeout(() => resolve(event), 1));
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Async Test Integration Missing Event Processor Setup

The AsyncTestIntegration defines a processEvent method but lacks a setupOnce or setup method to register it as an event processor. Without registration, the async processEvent won't execute, causing tests using this integration to pass incorrectly without actually exercising the promise buffer's async event handling logic.

Fix in CursorFix in Web

Comment threadpackages/core/src/client.ts Outdated
@github-actions

github-actionsBot commented Nov 7, 2025

Copy link
Copy Markdown
Contributor

size-limit report 📦

PathSize% ChangeChange
@sentry/browser24.7 kB+0.34%+82 B 🔺
@sentry/browser - with treeshaking flags23.2 kB+0.29%+66 B 🔺
@sentry/browser (incl. Tracing)41.43 kB+0.14%+55 B 🔺
@sentry/browser (incl. Tracing, Profiling)45.75 kB+0.15%+64 B 🔺
@sentry/browser (incl. Tracing, Replay)79.85 kB+0.04%+31 B 🔺
@sentry/browser (incl. Tracing, Replay) - with treeshaking flags69.57 kB+0.08%+49 B 🔺
@sentry/browser (incl. Tracing, Replay with Canvas)84.55 kB+0.06%+44 B 🔺
@sentry/browser (incl. Tracing, Replay, Feedback)96.77 kB+0.05%+45 B 🔺
@sentry/browser (incl. Feedback)41.38 kB+0.23%+94 B 🔺
@sentry/browser (incl. sendFeedback)29.39 kB+0.33%+94 B 🔺
@sentry/browser (incl. FeedbackAsync)34.33 kB+0.33%+111 B 🔺
@sentry/react26.41 kB+0.37%+97 B 🔺
@sentry/react (incl. Tracing)43.43 kB+0.26%+112 B 🔺
@sentry/vue29.15 kB+0.14%+38 B 🔺
@sentry/vue (incl. Tracing)43.23 kB+0.15%+64 B 🔺
@sentry/svelte24.72 kB+0.32%+78 B 🔺
CDN Bundle27.02 kB+0.24%+62 B 🔺
CDN Bundle (incl. Tracing)42.02 kB+0.17%+69 B 🔺
CDN Bundle (incl. Tracing, Replay)78.53 kB+0.05%+35 B 🔺
CDN Bundle (incl. Tracing, Replay, Feedback)84.02 kB+0.08%+62 B 🔺
CDN Bundle - uncompressed79.17 kB+0.29%+223 B 🔺
CDN Bundle (incl. Tracing) - uncompressed124.55 kB+0.18%+221 B 🔺
CDN Bundle (incl. Tracing, Replay) - uncompressed240.59 kB+0.1%+221 B 🔺
CDN Bundle (incl. Tracing, Replay, Feedback) - uncompressed253.35 kB+0.09%+221 B 🔺
@sentry/nextjs (client)45.84 kB+0.23%+104 B 🔺
@sentry/sveltekit (client)41.79 kB+0.07%+26 B 🔺
@sentry/node-core51.02 kB+0.14%+67 B 🔺
@sentry/node159.34 kB+0.05%+78 B 🔺
@sentry/node - without tracing92.9 kB+0.08%+70 B 🔺
@sentry/aws-serverless106.64 kB+0.07%+67 B 🔺

View base workflow run

@github-actions

github-actionsBot commented Nov 7, 2025

Copy link
Copy Markdown
Contributor

node-overhead report 🧳

Note: This is a synthetic benchmark with a minimal express app and does not necessarily reflect the real-world performance impact in an application.

ScenarioRequests/s% of BaselinePrev. Requests/sChange %
GET Baseline9,031-8,783+3%
GET With Sentry1,77720%1,354+31%
GET With Sentry (error only)6,27669%5,981+5%
POST Baseline1,220-1,200+2%
POST With Sentry59349%494+20%
POST With Sentry (error only)1,07388%1,036+4%
MYSQL Baseline3,399-3,258+4%
MYSQL With Sentry47614%464+3%
MYSQL With Sentry (error only)2,80182%2,673+5%

View base workflow run

@AbhiPrasadAbhiPrasad left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To me this feels a bit weird because it means we have two promise buffers in our pipeline, one to manage client processing state, and the other to manage the transport queue state.

The transport still needs a promise buffer to manage inflight requests because we need to make sure we don't saturate the user's network I/O.

I understand conceptually this is the best way to do it so giving it the ✅ (unless we totally overhaul _process, which honestly we might want to as a follow up).

We'll need the transport buffer size to be >= client buffer size, otherwise we risk backpressure issues. I think it's reasonable to assume the time it takes promises to resolve on the client buffer will be shorter than the transport buffer, so we could even make this size 32 instead of 64, or pick Math.ceil(transportOptions.bufferSize / 2)

It would be nice to just do a sanity check benchmark of the client buffer with a basic test app that we blast with load - we can use that to see if the buffer size assumptions hold up.

@JPeer264

Copy link
Copy Markdown
MemberAuthor

To me this feels a bit weird because it means we have two promise buffers in our pipeline, one to manage client processing state, and the other to manage the transport queue state.

To be fair I see this Promise buffer not as final solution, but more of mitigating the main issue. Overall there are quite some memory increases once we run into sync code

It would be nice to just do a sanity check benchmark of the client buffer with a basic test app that we blast with load - we can use that to see if the buffer size assumptions hold up.

So once we have code like above only 64 requests are taken and the rest are abandoned and removed so only 64 are going through - always, since the requests will be queued and not really processed further (unless we remove the async integrations). So in theory the second promise buffer doesn't do anything anymore in this scenario (that's just a guess at this point).

@AbhiPrasadAbhiPrasad left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So in theory the second promise buffer doesn't do anything anymore in this scenario (that's just a guess at this point).

the 2nd promise buffer tracks I/O instead of a transformed event, so I think it still matters.

Thinking about this more, the 2nd promise buffer probably resolves promises slower too, as it's based on request promises. It becomes the throughput bottleneck. If we size the first promise buffer to be too large, we will always drop events no matter what. So the size of the first promise buffer must be smaller than the second one.

Let's get this merged in and keep iterating.

@JPeer264
JPeer264 enabled auto-merge (squash) November 20, 2025 10:21
client.captureException(new Error('third'));

expect(client._clearOutcomes()).toEqual([]);
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Test expects wrong outcome for sync integrations

The test expects no dropped events when calling captureException three times with a buffer size of 1, but the promise buffer doesn't distinguish between sync and async integrations. With three synchronous calls and a buffer size of 1, the first call adds a promise to the buffer, and the second and third calls are rejected immediately because the buffer is full. The test should expect [{ reason: 'queue_overflow', category: 'error', quantity: 2 }] instead of an empty array.

Fix in CursorFix in Web

this._process(
() => promisedEvent.then(event => this._captureEvent(event, hintWithEventId, currentScope)),
isMessage ? 'unknown' : 'error',
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Promise created eagerly in captureMessage

In captureMessage, the promisedEvent is created outside the task producer function passed to _process. This means eventFromMessage or eventFromException is called immediately, even when the promise buffer is full. This defeats the lazy evaluation design of the promise buffer, causing unnecessary work when events should be dropped. The promise creation should be moved inside the task producer function to enable proper lazy evaluation.

Fix in CursorFix in Web

@JPeer264
JPeer264 merged commit c7e88d4 into developNov 20, 2025
379 of 383 checks passed
@JPeer264
JPeer264 deleted the jp/memory-leak branch November 20, 2025 16:43
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@JPeer264@AbhiPrasad
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(core): Add a PromiseBuffer for incoming events on the client - #18120

Merged
JPeer264 merged 4 commits into
developfrom
jp/memory-leak
Nov 20, 2025
Merged

fix(core): Add a PromiseBuffer for incoming events on the client#18120
JPeer264 merged 4 commits into
developfrom
jp/memory-leak

Conversation

@JPeer264

@JPeer264JPeer264 commented Nov 7, 2025

Copy link
Copy Markdown
Member

Problem

Previously, the client would process all incoming events without any limit, which could lead to unbounded growth of pending events/promises in memory. This could cause performance issues and memory pressure in high-throughput scenarios. This occurs when two conditions are met:

  • when an integration with an async processEvent are added (e.g. ContextLines, which is a defaultIntegration)
  • events, e.g. Sentry.captureException, are called synchronously
Sentry.init({ ... });// ...for(leti=0;i<5000;i++){Sentry.captureException(newError());}

Solution

This PR adds a PromiseBuffer to the Client class to limit the number of concurrent event processing operations.

  • Introduced a _promiseBuffer in the Client class that limits concurrent event processing
  • The buffer size defaults to DEFAULT_TRANSPORT_BUFFER_SIZE (64) but can be configured via transportOptions.bufferSize
  • When the buffer is full, events are rejected and properly tracked as dropped events with the queue_overflow reason
    • Please tak
  • Modified the _process() method to:
    • Accept a task producer function instead of a promise directly (lazy evaluation)
    • Use the promise buffer to manage concurrent operations
    • Track the data category for proper dropped event categorization

Special 👀 on

  • About reusing transportOptions.bufferSize: Not sure if this is the best technique, but IMO both should have the same size - because if it wouldn't it would be capped at a later stage (asking myself if the transport still needs the promise buffer - as we have it now way earlier in place)
  • The _process takes now a DataCategory. At the time of the process the event type is almost unknown. Not sure if I assumed the categories correctly there, or if there is another technique of getting the type (edit: a comment by Cursor helped a little and I added a helper function)
  • recordDroppedEvent is now printing it one after each other - theoretically we can count all occurences and print the count on it. I decided against this one, since it would delay the user feedback - this can be challenged though

@JPeer264
JPeer264force-pushed the jp/memory-leak branch 2 times, most recently from 619a016 to 1695dd4CompareNovember 7, 2025 15:43
processEvent(event: Event): Event | null | PromiseLike<Event | null> {
return new Promise(resolve => setTimeout(() => resolve(event), 1));
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Async Test Integration Missing Event Processor Setup

The AsyncTestIntegration defines a processEvent method but lacks a setupOnce or setup method to register it as an event processor. Without registration, the async processEvent won't execute, causing tests using this integration to pass incorrectly without actually exercising the promise buffer's async event handling logic.

Fix in CursorFix in Web

Comment threadpackages/core/src/client.ts Outdated
@github-actions

github-actionsBot commented Nov 7, 2025

Copy link
Copy Markdown
Contributor

size-limit report 📦

PathSize% ChangeChange
@sentry/browser24.7 kB+0.34%+82 B 🔺
@sentry/browser - with treeshaking flags23.2 kB+0.29%+66 B 🔺
@sentry/browser (incl. Tracing)41.43 kB+0.14%+55 B 🔺
@sentry/browser (incl. Tracing, Profiling)45.75 kB+0.15%+64 B 🔺
@sentry/browser (incl. Tracing, Replay)79.85 kB+0.04%+31 B 🔺
@sentry/browser (incl. Tracing, Replay) - with treeshaking flags69.57 kB+0.08%+49 B 🔺
@sentry/browser (incl. Tracing, Replay with Canvas)84.55 kB+0.06%+44 B 🔺
@sentry/browser (incl. Tracing, Replay, Feedback)96.77 kB+0.05%+45 B 🔺
@sentry/browser (incl. Feedback)41.38 kB+0.23%+94 B 🔺
@sentry/browser (incl. sendFeedback)29.39 kB+0.33%+94 B 🔺
@sentry/browser (incl. FeedbackAsync)34.33 kB+0.33%+111 B 🔺
@sentry/react26.41 kB+0.37%+97 B 🔺
@sentry/react (incl. Tracing)43.43 kB+0.26%+112 B 🔺
@sentry/vue29.15 kB+0.14%+38 B 🔺
@sentry/vue (incl. Tracing)43.23 kB+0.15%+64 B 🔺
@sentry/svelte24.72 kB+0.32%+78 B 🔺
CDN Bundle27.02 kB+0.24%+62 B 🔺
CDN Bundle (incl. Tracing)42.02 kB+0.17%+69 B 🔺
CDN Bundle (incl. Tracing, Replay)78.53 kB+0.05%+35 B 🔺
CDN Bundle (incl. Tracing, Replay, Feedback)84.02 kB+0.08%+62 B 🔺
CDN Bundle - uncompressed79.17 kB+0.29%+223 B 🔺
CDN Bundle (incl. Tracing) - uncompressed124.55 kB+0.18%+221 B 🔺
CDN Bundle (incl. Tracing, Replay) - uncompressed240.59 kB+0.1%+221 B 🔺
CDN Bundle (incl. Tracing, Replay, Feedback) - uncompressed253.35 kB+0.09%+221 B 🔺
@sentry/nextjs (client)45.84 kB+0.23%+104 B 🔺
@sentry/sveltekit (client)41.79 kB+0.07%+26 B 🔺
@sentry/node-core51.02 kB+0.14%+67 B 🔺
@sentry/node159.34 kB+0.05%+78 B 🔺
@sentry/node - without tracing92.9 kB+0.08%+70 B 🔺
@sentry/aws-serverless106.64 kB+0.07%+67 B 🔺

View base workflow run

@github-actions

github-actionsBot commented Nov 7, 2025

Copy link
Copy Markdown
Contributor

node-overhead report 🧳

Note: This is a synthetic benchmark with a minimal express app and does not necessarily reflect the real-world performance impact in an application.

ScenarioRequests/s% of BaselinePrev. Requests/sChange %
GET Baseline9,031-8,783+3%
GET With Sentry1,77720%1,354+31%
GET With Sentry (error only)6,27669%5,981+5%
POST Baseline1,220-1,200+2%
POST With Sentry59349%494+20%
POST With Sentry (error only)1,07388%1,036+4%
MYSQL Baseline3,399-3,258+4%
MYSQL With Sentry47614%464+3%
MYSQL With Sentry (error only)2,80182%2,673+5%

View base workflow run

@AbhiPrasadAbhiPrasad left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To me this feels a bit weird because it means we have two promise buffers in our pipeline, one to manage client processing state, and the other to manage the transport queue state.

The transport still needs a promise buffer to manage inflight requests because we need to make sure we don't saturate the user's network I/O.

I understand conceptually this is the best way to do it so giving it the ✅ (unless we totally overhaul _process, which honestly we might want to as a follow up).

We'll need the transport buffer size to be >= client buffer size, otherwise we risk backpressure issues. I think it's reasonable to assume the time it takes promises to resolve on the client buffer will be shorter than the transport buffer, so we could even make this size 32 instead of 64, or pick Math.ceil(transportOptions.bufferSize / 2)

It would be nice to just do a sanity check benchmark of the client buffer with a basic test app that we blast with load - we can use that to see if the buffer size assumptions hold up.

@JPeer264

Copy link
Copy Markdown
MemberAuthor

To me this feels a bit weird because it means we have two promise buffers in our pipeline, one to manage client processing state, and the other to manage the transport queue state.

To be fair I see this Promise buffer not as final solution, but more of mitigating the main issue. Overall there are quite some memory increases once we run into sync code

It would be nice to just do a sanity check benchmark of the client buffer with a basic test app that we blast with load - we can use that to see if the buffer size assumptions hold up.

So once we have code like above only 64 requests are taken and the rest are abandoned and removed so only 64 are going through - always, since the requests will be queued and not really processed further (unless we remove the async integrations). So in theory the second promise buffer doesn't do anything anymore in this scenario (that's just a guess at this point).

@AbhiPrasadAbhiPrasad left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So in theory the second promise buffer doesn't do anything anymore in this scenario (that's just a guess at this point).

the 2nd promise buffer tracks I/O instead of a transformed event, so I think it still matters.

Thinking about this more, the 2nd promise buffer probably resolves promises slower too, as it's based on request promises. It becomes the throughput bottleneck. If we size the first promise buffer to be too large, we will always drop events no matter what. So the size of the first promise buffer must be smaller than the second one.

Let's get this merged in and keep iterating.

@JPeer264
JPeer264 enabled auto-merge (squash) November 20, 2025 10:21
client.captureException(new Error('third'));

expect(client._clearOutcomes()).toEqual([]);
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Test expects wrong outcome for sync integrations

The test expects no dropped events when calling captureException three times with a buffer size of 1, but the promise buffer doesn't distinguish between sync and async integrations. With three synchronous calls and a buffer size of 1, the first call adds a promise to the buffer, and the second and third calls are rejected immediately because the buffer is full. The test should expect [{ reason: 'queue_overflow', category: 'error', quantity: 2 }] instead of an empty array.

Fix in CursorFix in Web

this._process(
() => promisedEvent.then(event => this._captureEvent(event, hintWithEventId, currentScope)),
isMessage ? 'unknown' : 'error',
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Promise created eagerly in captureMessage

In captureMessage, the promisedEvent is created outside the task producer function passed to _process. This means eventFromMessage or eventFromException is called immediately, even when the promise buffer is full. This defeats the lazy evaluation design of the promise buffer, causing unnecessary work when events should be dropped. The promise creation should be moved inside the task producer function to enable proper lazy evaluation.

Fix in CursorFix in Web

@JPeer264
JPeer264 merged commit c7e88d4 into developNov 20, 2025
379 of 383 checks passed
@JPeer264
JPeer264 deleted the jp/memory-leak branch November 20, 2025 16:43
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@JPeer264@AbhiPrasad
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

fix(core): Add a PromiseBuffer for incoming events on the client - #18120

Merged
JPeer264 merged 4 commits into
developfrom
jp/memory-leak
Nov 20, 2025
Merged

fix(core): Add a PromiseBuffer for incoming events on the client#18120
JPeer264 merged 4 commits into
developfrom
jp/memory-leak

Conversation

@JPeer264

@JPeer264JPeer264 commented Nov 7, 2025

Copy link
Copy Markdown
Member

Problem

Previously, the client would process all incoming events without any limit, which could lead to unbounded growth of pending events/promises in memory. This could cause performance issues and memory pressure in high-throughput scenarios. This occurs when two conditions are met:

  • when an integration with an async processEvent are added (e.g. ContextLines, which is a defaultIntegration)
  • events, e.g. Sentry.captureException, are called synchronously
Sentry.init({ ... });// ...for(leti=0;i<5000;i++){Sentry.captureException(newError());}

Solution

This PR adds a PromiseBuffer to the Client class to limit the number of concurrent event processing operations.

  • Introduced a _promiseBuffer in the Client class that limits concurrent event processing
  • The buffer size defaults to DEFAULT_TRANSPORT_BUFFER_SIZE (64) but can be configured via transportOptions.bufferSize
  • When the buffer is full, events are rejected and properly tracked as dropped events with the queue_overflow reason
    • Please tak
  • Modified the _process() method to:
    • Accept a task producer function instead of a promise directly (lazy evaluation)
    • Use the promise buffer to manage concurrent operations
    • Track the data category for proper dropped event categorization

Special 👀 on

  • About reusing transportOptions.bufferSize: Not sure if this is the best technique, but IMO both should have the same size - because if it wouldn't it would be capped at a later stage (asking myself if the transport still needs the promise buffer - as we have it now way earlier in place)
  • The _process takes now a DataCategory. At the time of the process the event type is almost unknown. Not sure if I assumed the categories correctly there, or if there is another technique of getting the type (edit: a comment by Cursor helped a little and I added a helper function)
  • recordDroppedEvent is now printing it one after each other - theoretically we can count all occurences and print the count on it. I decided against this one, since it would delay the user feedback - this can be challenged though

@JPeer264
JPeer264force-pushed the jp/memory-leak branch 2 times, most recently from 619a016 to 1695dd4CompareNovember 7, 2025 15:43
processEvent(event: Event): Event | null | PromiseLike<Event | null> {
return new Promise(resolve => setTimeout(() => resolve(event), 1));
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Async Test Integration Missing Event Processor Setup

The AsyncTestIntegration defines a processEvent method but lacks a setupOnce or setup method to register it as an event processor. Without registration, the async processEvent won't execute, causing tests using this integration to pass incorrectly without actually exercising the promise buffer's async event handling logic.

Fix in CursorFix in Web

Comment threadpackages/core/src/client.ts Outdated
@github-actions

github-actionsBot commented Nov 7, 2025

Copy link
Copy Markdown
Contributor

size-limit report 📦

PathSize% ChangeChange
@sentry/browser24.7 kB+0.34%+82 B 🔺
@sentry/browser - with treeshaking flags23.2 kB+0.29%+66 B 🔺
@sentry/browser (incl. Tracing)41.43 kB+0.14%+55 B 🔺
@sentry/browser (incl. Tracing, Profiling)45.75 kB+0.15%+64 B 🔺
@sentry/browser (incl. Tracing, Replay)79.85 kB+0.04%+31 B 🔺
@sentry/browser (incl. Tracing, Replay) - with treeshaking flags69.57 kB+0.08%+49 B 🔺
@sentry/browser (incl. Tracing, Replay with Canvas)84.55 kB+0.06%+44 B 🔺
@sentry/browser (incl. Tracing, Replay, Feedback)96.77 kB+0.05%+45 B 🔺
@sentry/browser (incl. Feedback)41.38 kB+0.23%+94 B 🔺
@sentry/browser (incl. sendFeedback)29.39 kB+0.33%+94 B 🔺
@sentry/browser (incl. FeedbackAsync)34.33 kB+0.33%+111 B 🔺
@sentry/react26.41 kB+0.37%+97 B 🔺
@sentry/react (incl. Tracing)43.43 kB+0.26%+112 B 🔺
@sentry/vue29.15 kB+0.14%+38 B 🔺
@sentry/vue (incl. Tracing)43.23 kB+0.15%+64 B 🔺
@sentry/svelte24.72 kB+0.32%+78 B 🔺
CDN Bundle27.02 kB+0.24%+62 B 🔺
CDN Bundle (incl. Tracing)42.02 kB+0.17%+69 B 🔺
CDN Bundle (incl. Tracing, Replay)78.53 kB+0.05%+35 B 🔺
CDN Bundle (incl. Tracing, Replay, Feedback)84.02 kB+0.08%+62 B 🔺
CDN Bundle - uncompressed79.17 kB+0.29%+223 B 🔺
CDN Bundle (incl. Tracing) - uncompressed124.55 kB+0.18%+221 B 🔺
CDN Bundle (incl. Tracing, Replay) - uncompressed240.59 kB+0.1%+221 B 🔺
CDN Bundle (incl. Tracing, Replay, Feedback) - uncompressed253.35 kB+0.09%+221 B 🔺
@sentry/nextjs (client)45.84 kB+0.23%+104 B 🔺
@sentry/sveltekit (client)41.79 kB+0.07%+26 B 🔺
@sentry/node-core51.02 kB+0.14%+67 B 🔺
@sentry/node159.34 kB+0.05%+78 B 🔺
@sentry/node - without tracing92.9 kB+0.08%+70 B 🔺
@sentry/aws-serverless106.64 kB+0.07%+67 B 🔺

View base workflow run

@github-actions

github-actionsBot commented Nov 7, 2025

Copy link
Copy Markdown
Contributor

node-overhead report 🧳

Note: This is a synthetic benchmark with a minimal express app and does not necessarily reflect the real-world performance impact in an application.

ScenarioRequests/s% of BaselinePrev. Requests/sChange %
GET Baseline9,031-8,783+3%
GET With Sentry1,77720%1,354+31%
GET With Sentry (error only)6,27669%5,981+5%
POST Baseline1,220-1,200+2%
POST With Sentry59349%494+20%
POST With Sentry (error only)1,07388%1,036+4%
MYSQL Baseline3,399-3,258+4%
MYSQL With Sentry47614%464+3%
MYSQL With Sentry (error only)2,80182%2,673+5%

View base workflow run

@AbhiPrasadAbhiPrasad left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To me this feels a bit weird because it means we have two promise buffers in our pipeline, one to manage client processing state, and the other to manage the transport queue state.

The transport still needs a promise buffer to manage inflight requests because we need to make sure we don't saturate the user's network I/O.

I understand conceptually this is the best way to do it so giving it the ✅ (unless we totally overhaul _process, which honestly we might want to as a follow up).

We'll need the transport buffer size to be >= client buffer size, otherwise we risk backpressure issues. I think it's reasonable to assume the time it takes promises to resolve on the client buffer will be shorter than the transport buffer, so we could even make this size 32 instead of 64, or pick Math.ceil(transportOptions.bufferSize / 2)

It would be nice to just do a sanity check benchmark of the client buffer with a basic test app that we blast with load - we can use that to see if the buffer size assumptions hold up.

@JPeer264

Copy link
Copy Markdown
MemberAuthor

To me this feels a bit weird because it means we have two promise buffers in our pipeline, one to manage client processing state, and the other to manage the transport queue state.

To be fair I see this Promise buffer not as final solution, but more of mitigating the main issue. Overall there are quite some memory increases once we run into sync code

It would be nice to just do a sanity check benchmark of the client buffer with a basic test app that we blast with load - we can use that to see if the buffer size assumptions hold up.

So once we have code like above only 64 requests are taken and the rest are abandoned and removed so only 64 are going through - always, since the requests will be queued and not really processed further (unless we remove the async integrations). So in theory the second promise buffer doesn't do anything anymore in this scenario (that's just a guess at this point).

@AbhiPrasadAbhiPrasad left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So in theory the second promise buffer doesn't do anything anymore in this scenario (that's just a guess at this point).

the 2nd promise buffer tracks I/O instead of a transformed event, so I think it still matters.

Thinking about this more, the 2nd promise buffer probably resolves promises slower too, as it's based on request promises. It becomes the throughput bottleneck. If we size the first promise buffer to be too large, we will always drop events no matter what. So the size of the first promise buffer must be smaller than the second one.

Let's get this merged in and keep iterating.

@JPeer264
JPeer264 enabled auto-merge (squash) November 20, 2025 10:21
client.captureException(new Error('third'));

expect(client._clearOutcomes()).toEqual([]);
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Test expects wrong outcome for sync integrations

The test expects no dropped events when calling captureException three times with a buffer size of 1, but the promise buffer doesn't distinguish between sync and async integrations. With three synchronous calls and a buffer size of 1, the first call adds a promise to the buffer, and the second and third calls are rejected immediately because the buffer is full. The test should expect [{ reason: 'queue_overflow', category: 'error', quantity: 2 }] instead of an empty array.

Fix in CursorFix in Web

this._process(
() => promisedEvent.then(event => this._captureEvent(event, hintWithEventId, currentScope)),
isMessage ? 'unknown' : 'error',
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Promise created eagerly in captureMessage

In captureMessage, the promisedEvent is created outside the task producer function passed to _process. This means eventFromMessage or eventFromException is called immediately, even when the promise buffer is full. This defeats the lazy evaluation design of the promise buffer, causing unnecessary work when events should be dropped. The promise creation should be moved inside the task producer function to enable proper lazy evaluation.

Fix in CursorFix in Web

@JPeer264
JPeer264 merged commit c7e88d4 into developNov 20, 2025
379 of 383 checks passed
@JPeer264
JPeer264 deleted the jp/memory-leak branch November 20, 2025 16:43
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@JPeer264@AbhiPrasad