I wrote a flood test for the error tracker I'm building: one bug thrown 997 times in a retry loop, plus three rare bugs thrown once each. In the run that looked most like production, all three rare bugs vanished before they left the Node process.
Then I tried the fix everyone reaches for, sampleRate: 0.1. Across five runs, 2 of the 15 rare errors made it. The loop that caused the mess was still the biggest thing on the screen.
TL;DR sampleRate drops errors at random. It cuts the noisy loop by 90% and your one-off bugs by the same 90%, so the error you needed is the one most likely to disappear. The SDK drops events before sampling even runs: Dedupe eats exact repeats, and a tight burst overflows the 64-slot transport buffer. What worked: send everything, then rate limit stored bodies per fingerprint on the server. Count 100% of hits, keep the first 100 stack traces per bug per minute, let every new bug through.
Because it doesn't know which error is the flood. sampleRate is a per-event coin flip in the SDK. At 0.1, every error has a 10% chance of being sent, whether it's the 900th copy of the same TypeError or the only RangeError you'll see all week.
That's fine for volume and bad for signal. A bug that fires once has a 90% chance of never reaching you. The count you do see is off by about 10x, because the server gets roughly 100 events and nothing in them says "multiply me". Client reports tell the server how many events were dropped and why (samplerate, queueoverflow), but not which bug they belonged to.
You can reproduce this in a few minutes with Node 20.19 or newer. No Docker, no account. ingest.js is a fake endpoint that parses just enough of the Sentry envelope format to count errors per fingerprint. flood.js uses the real @sentry/node SDK.
ingest.js. The fingerprint is the exception type plus the top in-app frame (file, function, line). The valve is a token bucket per fingerprint: 100 tokens, refilled at 100 per minute.
Run the ingest in one terminal and the flood in another. The ingest keeps state in memory, so restart it between runs.
Before any sampling, the SDK already throws events away. Here's the naive run: defaults, no pacing, valve off.
997 TypeErrors thrown, 4 received. That's dedupeIntegration, on by default. It compares each event with the previous one and drops exact repeats, so you get one TypeError per stretch between the rare errors. With debug: true you'll see this 993 times:
Real floods come from many browsers or workers, each with its own "previous event". So I turned Dedupe off:
This is the run that bit me. 64 events arrived and all three rare bugs were gone. The transport allows 64 requests in flight (DEFAULTTRANSPORTBUFFERSIZE = 64 in @sentry/core). A synchronous burst fills it and everything after is dropped. With debug: true, 936 times:
In the lab, pacing with await Sentry.flush() every 50 events fixes it, and so does transportOptions: { bufferSize: 2000 }. Both gave me 997 + 1 + 1 + 1. In a real incident, a bigger buffer just means you're queueing the flood in your app's memory.
Now the popular fix. Paced, Dedupe off, valve off, sampleRate at 0.1, five runs:
That was one of my two lucky runs: one rare bug out of three. The other lucky run caught the SMTP error instead. The remaining three runs got 96, 102 and 85 TypeErrors and zero rare bugs. Yours will differ, it's random. That's the point.
| | Loop bug (thrown 997x) | Rare bugs (3 per run) | | --- | --- | --- | | Arrived over 5 runs | 85 to 120 per run | 2 of 15 | | What you conclude | count is off by about 10x | most of them never happened |
The loop still owns the issue list. The bugs you'd want to look at are the ones that disappeared.
Three runs, same output each time. The count is exact. The loop bug keeps 100 full stack traces and the rest are counted, not stored. Each rare bug gets its own bucket with 100 fresh tokens, so it always lands.
