Skip to content

Conversation

@JPeer264
Copy link
Member

@JPeer264 JPeer264 commented Nov 7, 2025

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 (let i = 0; i < 5000; i++) {
  Sentry.captureException(new Error());
}

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 JPeer264 force-pushed the jp/memory-leak branch 2 times, most recently from 619a016 to 1695dd4 Compare November 7, 2025 15:43
processEvent(event: Event): Event | null | PromiseLike<Event | null> {
return new Promise(resolve => setTimeout(() => resolve(event), 1));
}
}
Copy link

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 Cursor Fix in Web

@github-actions
Copy link
Contributor

github-actions bot commented Nov 7, 2025

size-limit report 📦

Path Size % Change Change
@sentry/browser 24.7 kB +0.34% +82 B 🔺
@sentry/browser - with treeshaking flags 23.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 flags 69.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/react 26.41 kB +0.37% +97 B 🔺
@sentry/react (incl. Tracing) 43.43 kB +0.26% +112 B 🔺
@sentry/vue 29.15 kB +0.14% +38 B 🔺
@sentry/vue (incl. Tracing) 43.23 kB +0.15% +64 B 🔺
@sentry/svelte 24.72 kB +0.32% +78 B 🔺
CDN Bundle 27.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 - uncompressed 79.17 kB +0.29% +223 B 🔺
CDN Bundle (incl. Tracing) - uncompressed 124.55 kB +0.18% +221 B 🔺
CDN Bundle (incl. Tracing, Replay) - uncompressed 240.59 kB +0.1% +221 B 🔺
CDN Bundle (incl. Tracing, Replay, Feedback) - uncompressed 253.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-core 51.02 kB +0.14% +67 B 🔺
@sentry/node 159.34 kB +0.05% +78 B 🔺
@sentry/node - without tracing 92.9 kB +0.08% +70 B 🔺
@sentry/aws-serverless 106.64 kB +0.07% +67 B 🔺

View base workflow run

@github-actions
Copy link
Contributor

github-actions bot commented Nov 7, 2025

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.

Scenario Requests/s % of Baseline Prev. Requests/s Change %
GET Baseline 9,031 - 8,783 +3%
GET With Sentry 1,777 20% 1,354 +31%
GET With Sentry (error only) 6,276 69% 5,981 +5%
POST Baseline 1,220 - 1,200 +2%
POST With Sentry 593 49% 494 +20%
POST With Sentry (error only) 1,073 88% 1,036 +4%
MYSQL Baseline 3,399 - 3,258 +4%
MYSQL With Sentry 476 14% 464 +3%
MYSQL With Sentry (error only) 2,801 82% 2,673 +5%

View base workflow run

Copy link
Member

@AbhiPrasad AbhiPrasad left a comment

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
Member Author

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).

Copy link
Member

@AbhiPrasad AbhiPrasad left a comment

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

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 Cursor Fix in Web

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

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 Cursor Fix in Web

@JPeer264 JPeer264 merged commit c7e88d4 into develop Nov 20, 2025
379 of 383 checks passed
@JPeer264 JPeer264 deleted the jp/memory-leak branch November 20, 2025 16:43
Sign up for free to 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.

3 participants