Skip to content

Engineering

Duplicate error reports: why one failure arrives twice and 20 arrive once

Reporting an error and then rethrowing it did not double it in our lab. Reporting it and then throwing a wrapper did. Six patterns, counted with the Sentry browser SDK and our own.

Andrew Babo4 min read

Share
On this page
Duplicate error reports: why one failure arrives twice and 20 arrive once

Reporting an error and then rethrowing it does not send it twice. Wrapping it in a new error does. And twenty identical throws in a row can arrive as one event, while two errors that take turns arrive twenty times.

Those are the counts from a lab we ran on 26 October 2026: six reporting patterns, each on a fresh page in Chromium 141, three runs each, with the Sentry browser SDK at its defaults (@sentry/browser 10.76.2), the same SDK without its Dedupe integration, and our own @belvador/browser 0.5.1. Every run gave the same numbers.

What we counted#

Events received for six reporting patterns
Lab 26 Oct 2026 · Chromium 141 · 3 runs per setup, same counts
PatternSentry defaultsSentry without DedupeBelvador
captureException(e), then throw e111
captureException(e) twice111
captureException(e), then throw new Error("checkout failed", { cause: e })222
captureException(e) in .catch, then rethrow111
The same failure 20 times in a row (a new, identical Error each time)1201
Two errors taking turns, 20 throws202020

The same object is reported once#

Reporting an error and rethrowing it is a common pattern: you want the event with your context, and you want the caller to fail as before. It did not double anything, even with Dedupe turned off.

The reason is in the SDK's core. Every capture first calls checkOrSetAlreadyCaught, which marks the error object with a hidden __sentry_captured__ property, or skips it if the mark is already there. The rethrown error is the same object, so the global handler that catches it finds the mark and stops.

The same source code notes the limit: a primitive cannot be marked. A thrown string has no property to set. We did not count that case here, but it is one more reason to throw Error objects, alongside the stack they carry, which our rejection lab measured.

The mark also lives only on that object, in that process. When TanStack Start rebuilds a server error in the browser, the copy has no mark, and our loader lab got two events for one failure until the server handed over which error it had already sent.

Wrapping makes a second error#

new Error("checkout failed", { cause: e }) is good practice. MDN describes cause as the place for “the specific original cause of the error”, and the SDK sent both: the wrapper's event carried two exceptions in its chain, the wrapper and the original.

But the wrapper is a new object without the mark, so after an earlier captureException(e) the original shows up twice: once alone, once as the cause. Pick one place to report: either capture the original and rethrow it unchanged, or wrap it and let the wrapper be the only report.

TypeScript
// Either: report here, rethrow the same errorcatch (e) { captureException(e); throw e; }// Or: wrap, and report only the wrapper (higher up, or uncaught)catch (e) { throw new Error("checkout failed", { cause: e }); }

Dedupe drops only what came just before#

The Sentry docs say the Dedupe integration “deduplicates certain events” and “only compares stack traces and fingerprints”. In practice it compares each event with the previous one.

Twenty throws, identical or alternating
Lab 26 Oct 2026 · Chromium 141 · @sentry/browser 10.76.2 · 3 runs, same counts

Twenty identical throws in a row became one event. Without Dedupe they were twenty. Two errors that take turns, A B A B, were twenty events either way, because no event was identical to the one just before it. A loop that fails the same way every time is quiet; a page with two broken widgets on a timer is not.

So Dedupe protects your quota from a tight loop, which is useful. It is not grouping. Twenty events of one failure still belong in one issue, and that is the error tracker's job on the server, not the SDK's.

Checklist#

  1. Report each failure in one place. Capture and rethrow the same object, or wrap and report only the wrapper.
  2. Throw Error objects so the SDK can mark them.
  3. Keep Dedupe on in the browser. It cut 20 events to 1 in our loop case.
  4. Expect duplicates across processes, such as server and browser, to need their own handoff.
  5. When counts look wrong, reproduce the pattern once and count what arrives, before changing grouping.

Where Belvador fits#

@belvador/browser keeps the engine's mark and Dedupe, so it counted the same as Sentry's defaults here. On the node, Belvador groups events into issues with a versioned algorithm, so twenty events of one failure are one issue with a count, and a new grouping version never regroups old issues. Setup is in the browser SDK guide, and the React page covers React apps. Once duplicates are gone, deciding what deserves an alert is the next step.

Keep reading#

Part of the guide: JavaScript error handling for AI-generated apps: where errors escape

All posts

Read next in the docs

See your own production errors this way

Belvador runs on your own Hetzner server: errors, cron jobs, uptime and traces, with your data staying yours.