Skip to content

fix(browser-utils): Fix soft navigation vital correlation race - #24519

Open
isaacs wants to merge 2 commits into
developfrom
isaacs/fix-24480
Open

isaacs wants to merge 2 commits into
developfrom
isaacs/fix-24480

Conversation

@isaacs

@isaacs isaacs commented Sep 19, 2026

Copy link
Copy Markdown
Member

Soft navigation CLS, LCP and INP are joined to their navigation span through the interaction that triggered the navigation. The join only worked in one direction: spanStart parked the span in _pendingNavigation, and the Event Timing handler consumed it. An entry that arrived before the span saw no pending navigation, skipped, and was never reconsidered, so its interactionId never reached _interactionIdToNavigationSpan and all three vitals for that navigation were dropped.

The two events race and neither is under the SDK's control. Entry delivery follows the paint after the interaction, while the navigation span starts from framework router code on the main thread. Under load the router code can slip behind the paint. Vue is the most exposed, because vueIntegration starts the span from a router.beforeEach guard.

Make the join work from either side. Entries with no matching pending navigation now go into a capped list, and spanStart claims a matching one before parking the span. The match rule and the 5ms tolerance are unchanged, so this does not loosen what counts as a match. It only drops the requirement that the span be registered first.

This also fixes a second miss the old code had. It discarded any entry that failed the match against the current _pendingNavigation. A navigation whose entry never arrived left a stale pending span behind, and the next navigation's early entry was then thrown away against it.

Two guards keep the new list from misattributing a vital, since an unconsumed entry now outlives the moment it arrived. A navigation span cannot claim an interaction that another navigation span already claimed, which matters because one interaction delivers several entries and only one of them binds. And a navigation span cannot claim an interaction older than 1.5s, so a programmatic router.push cannot inherit the last click on the page however long ago it happened. That window is the one browserTracingIntegration already uses to decide whether a navigation followed a click.

ref: #24354, #24366
Fixes #24480

Soft navigation CLS, LCP, and INP are joined to their navigation span
through the interaction that triggered the navigation. The join only
worked in one direction: `spanStart` parked the span in
`_pendingNavigation`, and the Event Timing handler consumed it.

An entry that arrived before the span saw no pending navigation,
skipped, and was never reconsidered, so its `interactionId` never
reached `_interactionIdToNavigationSpan` and all three vitals for that
navigation were dropped.

The two events race and neither is under the SDK's control. Entry
delivery follows the paint after the interaction, while the navigation
span starts from framework router code on the main thread. Under load
the router code can slip behind the paint.

Make the join work from either side. Entries with no matching pending
navigation now go into a capped list, and `spanStart` claims a matching
one before parking the span. The match rule and the 5ms tolerance are
unchanged, so this does not loosen what counts as a match. It only drops
the requirement that the span be registered first.

This also fixes a second miss. It would discard any entry that failed
the match against the current `_pendingNavigation`. A navigation whose
entry never arrived left a stale pending span behind, and the next
navigation's early entry was then thrown away against it.

ref: #24354, #24366
fix: #24480

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@isaacs
isaacs requested a review from a team as a code owner September 19, 2026 02:38
@isaacs
isaacs requested review from logaretm and msonnb and removed request for a team September 19, 2026 02:38
@isaacs

isaacs commented Sep 19, 2026

Copy link
Copy Markdown
Member Author

Left the two prior issues as ref rather than fix so that we can know to circle back and check for flakiness when this lands, since it's somewhat theoretical that this is truly the root cause.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Want reviews to match your repository better? Bugbot Learning can learn team-specific rules from PR activity. A team admin can enable Learning in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit d78bb0f. Configure here.

Comment thread packages/browser-utils/src/web-vitals/softNavs.ts
@github-actions

github-actions Bot commented Sep 19, 2026

Copy link
Copy Markdown
Contributor

size-limit report 📦

Path Size % Change Change
@sentry/browser 29.09 kB - -
@sentry/browser - with treeshaking flags 27.35 kB - -
@sentry/browser - with treeshaking flags tracing without tracing 27.26 kB - -
@sentry/browser (incl. Tracing) 50.65 kB +0.18% +87 B 🔺
@sentry/browser (incl. Tracing + Span Streaming) 50.67 kB +0.19% +92 B 🔺
@sentry/browser (incl. Tracing, Profiling) 53.67 kB +0.2% +105 B 🔺
@sentry/browser (incl. Tracing, Replay) 90.21 kB +0.12% +100 B 🔺
@sentry/browser (incl. Tracing, Replay) - with treeshaking flags 79.32 kB +0.14% +110 B 🔺
@sentry/browser (incl. Tracing, Replay with Canvas) 94.91 kB +0.11% +99 B 🔺
@sentry/browser (incl. Tracing, Replay, Feedback) 107.87 kB +0.09% +92 B 🔺
@sentry/browser (incl. Feedback) 46.62 kB - -
@sentry/browser (incl. sendFeedback) 34.15 kB - -
@sentry/browser (incl. FeedbackAsync) 39.26 kB - -
@sentry/browser (incl. Metrics) 30.1 kB - -
@sentry/browser (incl. Logs) 30.35 kB - -
@sentry/browser (incl. Metrics & Logs) 31.02 kB - -
@sentry/react 30.84 kB - -
@sentry/react (incl. Tracing) 52.98 kB +0.2% +105 B 🔺
@sentry/vue 36.34 kB - -
@sentry/vue (incl. Tracing) 52.96 kB +0.2% +101 B 🔺
@sentry/svelte 29.11 kB - -
CDN Bundle 30.8 kB - -
CDN Bundle (incl. Tracing) 51.2 kB +0.2% +99 B 🔺
CDN Bundle (incl. Logs, Metrics) 33.06 kB - -
CDN Bundle (incl. Tracing, Logs, Metrics) 53.19 kB +0.18% +92 B 🔺
CDN Bundle (incl. Replay, Logs, Metrics) 73.75 kB - -
CDN Bundle (incl. Tracing, Replay) 88.75 kB +0.11% +89 B 🔺
CDN Bundle (incl. Tracing, Replay, Logs, Metrics) 90.68 kB +0.12% +101 B 🔺
CDN Bundle (incl. Tracing, Replay, Feedback) 94.79 kB +0.1% +94 B 🔺
CDN Bundle (incl. Tracing, Replay, Feedback, Logs, Metrics) 96.83 kB +0.13% +117 B 🔺
CDN Bundle - uncompressed 91.16 kB - -
CDN Bundle (incl. Tracing) - uncompressed 152.85 kB +0.21% +315 B 🔺
CDN Bundle (incl. Logs, Metrics) - uncompressed 97.73 kB - -
CDN Bundle (incl. Tracing, Logs, Metrics) - uncompressed 158.8 kB +0.2% +315 B 🔺
CDN Bundle (incl. Replay, Logs, Metrics) - uncompressed 227.14 kB - -
CDN Bundle (incl. Tracing, Replay) - uncompressed 272.42 kB +0.12% +315 B 🔺
CDN Bundle (incl. Tracing, Replay, Logs, Metrics) - uncompressed 278.36 kB +0.12% +315 B 🔺
CDN Bundle (incl. Tracing, Replay, Feedback) - uncompressed 286.12 kB +0.12% +315 B 🔺
CDN Bundle (incl. Tracing, Replay, Feedback, Logs, Metrics) - uncompressed 292.05 kB +0.11% +315 B 🔺
@sentry/nextjs (client) 55.31 kB +0.19% +102 B 🔺
@sentry/sveltekit (client) 51.1 kB +0.21% +105 B 🔺
@sentry/core/server 39.98 kB - -
@sentry/core/browser 13.66 kB - -
@sentry/node 133.34 kB +0.02% +25 B 🔺
@sentry/node/import (ESM hook with diagnostics-channel injection) 82.17 kB - -
@sentry/node - without tracing 89.93 kB +0.03% +22 B 🔺
@sentry/node - without channel injection 112.15 kB +0.02% +15 B 🔺
@sentry/aws-serverless 98.19 kB +0.03% +20 B 🔺
@sentry/cloudflare (withSentry) - minified 204.69 kB - -
@sentry/cloudflare (withSentry) 509.25 kB - -

View base workflow run

_lastInteractionTimestamp != null ? { span, interactionTimestamp: _lastInteractionTimestamp } : undefined;
_pendingNavigation = undefined;
const interactionTimestamp = _lastInteractionTimestamp;
if (interactionTimestamp == null || performance.now() - interactionTimestamp > MAX_INTERACTION_AGE_MS) {

@logaretm logaretm Sep 19, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This check also runs on the span-first path, and it's measured from event.timeStamp, so a click with a handler that takes >1.5s never binds.

I tried a 2s handler in Chrome 153, the span still starts before the entries and Chrome emits the soft-nav with the same interactionId, but we'd drop all three vitals. on develop branch this doesn't happen and those are the INPs we need to catch because they would represent really bad values.

Can we drop the check?

* the interaction a navigation happens during can match it, so a handful is plenty, and the cap
* stops a page with many interactions and no navigations from growing the list.
*/
const MAX_UNBOUND_INTERACTIONS = 20;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

spanStart only looks for _lastInteractionTimestamp, which only moves forward, so an entry that doesn't match it on arrival can never be claimed.

We could keep a single slot instead of the list and drop the cap and its test.

performanceHandlers.clear();
vi.stubGlobal('PerformanceObserver', { supportedEntryTypes: ['event', 'soft-navigation'] });
// Pinned so the fixtures' interaction timestamps below stay inside `MAX_INTERACTION_AGE_MS`.
vi.spyOn(performance, 'now').mockReturnValue(1500);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Pinning this to 1500 for every test hides the span-first case with an old click.

Can we add a click at 1000, performance.now() at 3500, startSpan, then the entry, and expect it to bind?

@logaretm

Copy link
Copy Markdown
Member

I didn't want to overcomplicate the correlation when I was working on this because web vitals will move to metrics soon which would allow for looser correlation.

Still if we already see those in tests then we should fix them, I think the approach is sound in general even if it doesn't fix the flakes but I think we have a few cases I pointed out where we drop some vitals that we should instead catch.

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.

Soft navigation web vitals are dropped when the navigation span starts after the interaction's Event Timing entry

2 participants