Soft navigation web vitals are dropped when the navigation span starts after the interaction's Event Timing entry
Nobody has claimed this yet.
Assessment
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Newbie friendliness
- 68/100
- Issue type
- Bug
- Clarity
- Clearly specified
- Activity status
- Active
- Tech stack
- typescript
- Domain
- frontend, observability
Research direction
Start with packages/browser-utils/src/web-vitals/softNavs.ts, especially the spanStart and bindInteractionToNavigationSpan handlers, then trace trackWebVitalPerNavigation in spans.ts. Run tests/soft-navigation-web-vitals.test.ts; done means soft-navigation CLS, LCP, and INP remain correlated when the Event Timing entry arrives before the navigation span, without unbounded retained entries.
Written by the indexing model from the issue text.
Description
permalink to CI run showing failure
Existing reports
This is already reported twice, both auto-filed with "Flakiness Type: Other / Unknown" and no diagnosis:
- #24354 —
E2E vue-3 Test, filed 2026-09-12 (JS-3669) - #24366 —
E2E vue-3 (latest) Test, filed 2026-09-14 (JS-3673)
Both are the same test in the same app, reported separately because they ran as different build variants. A third variant, E2E vue-3 (no Options API) Test, has now failed the same way.
So this is one SDK defect showing up as three per-variant flaky reports. Worth consolidating: the two open issues are duplicates of each other, and none of the three is really a test problem. Filing this against browser-utils and linking them as symptoms is probably cleaner than adding a third flaky report.
No flaky issues exist yet for the react-router-6, react-create-browser-router or angular-22 copies of the test, though they are exposed to the same race (see Impact below).
Summary
Soft navigation web vitals (CLS, LCP, INP) are correlated to their navigation span through the interaction that triggered the navigation. The correlation only works in one direction: the navigation span has to already exist when the browser delivers the interaction's Event Timing entry. If the entry arrives first, nothing binds, and the vital is dropped rather than reported.
Both events are outside the SDK's control and race each other. 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.
Where
packages/browser-utils/src/web-vitals/softNavs.ts
The binding is one-shot and requires the span to come first:
// :100 — a navigation span makes itself the pending candidate
client.on('spanStart', span => {
if (spanToJSON(span).attributes?.[SEMANTIC_ATTRIBUTE_SENTRY_OP] !== 'navigation') {
return;
}
_pendingNavigation =
_lastInteractionTimestamp != null ? { span, interactionTimestamp: _lastInteractionTimestamp } : undefined;
});
// :111 — an Event Timing entry claims the pending candidate, if there is one
const bindInteractionToNavigationSpan = ({ entries }) => {
for (const entry of entries) {
const pending = _pendingNavigation;
if (!pending || !isPerformanceEventTiming(entry) || !entry.interactionId) {
continue; // <-- entry arrived first, binding lost for good
}
if (Math.abs(entry.startTime - pending.interactionTimestamp) > INTERACTION_MATCH_TOLERANCE_MS) {
continue;
}
_interactionIdToNavigationSpan.set(entry.interactionId, pending.span);
_pendingNavigation = undefined;
}
};
_pendingNavigation is only ever set by spanStart. An entry that arrives before the span sees !pending, skips, and is never reconsidered, so the interactionId never reaches _interactionIdToNavigationSpan.
The result is a silent drop in trackWebVitalPerNavigation (packages/browser-utils/src/web-vitals/spans.ts:88):
const navigationSpan = getNavigationSpanForMetric(metric);
if (metric.navigationType === 'soft-navigation') {
if (navigationSpan) {
send(metric, navigationSpan, metric.navigationId);
} else {
DEBUG_BUILD &&
debug.log(`[SoftNav] Dropping ${metric.name} for uncorrelated soft navigation ${metric.navigationId}`);
}
return;
}
Dropping is deliberate and correct: attributing the vital to the wrong route would be worse than losing it. The problem is how often the correlation fails, not what happens when it does.
getNavigationSpanForMetric has a fallback for a different ordering problem, when the soft-navigation observer has not run yet, via metric.navigationInteractionId. That fallback still reads _interactionIdToNavigationSpan, so it does not help here: if nothing ever bound the interactionId, both lookups miss.
Impact
All three per-navigation vitals go through trackWebVitalPerNavigation, so a lost binding drops the soft navigation's CLS (spans.ts:222), LCP (spans.ts:122) and INP (spans.ts:327) together.
In production this is silent data loss for soft navigations, weighted toward slow devices and loaded pages, which is where the vitals matter most. The drop is only visible with debug logging on.
How it shows up in CI
E2E vue-3 (no Options API) Test timed out after 30s in tests/soft-navigation-web-vitals.test.ts, waiting for a ui.webvital.cls span that was never sent. 12 of 13 tests passed. The two runs already filed as #24354 and #24366 are the same failure on other variants of the same app.
Vue is the most exposed of the router instrumentations because vueIntegration starts the navigation span from a router.beforeEach guard. The same test exists for four apps, and all four depend on the same ordering:
dev-packages/e2e-tests/test-applications/vue-3dev-packages/e2e-tests/test-applications/react-router-6dev-packages/e2e-tests/test-applications/react-create-browser-routerdev-packages/e2e-tests/test-applications/angular-22
Each of those test files already documents the constraint in its header comment: the correlation "only holds while the navigation span is started before the interaction's Event Timing entry is delivered."
The tests cannot work around this. They control the click and the hide, but not the interleaving of the router guard and the paint.
Suggested fix
Make the binding work from either direction. Keep a short list of recent unbound interactionIds with their startTime, and have spanStart claim a matching one when the entry got there first:
bindInteractionToNavigationSpanrecords{ interactionId, startTime }when there is no pending navigation, instead of discarding the entry.- The
spanStarthandler checks that list against_lastInteractionTimestampusing the existingINTERACTION_MATCH_TOLERANCE_MS(5ms) window, and binds on a hit.
The matching rule and tolerance stay as they are, so this does not loosen what counts as a match. It only removes the requirement that the span be registered first. The list needs a cap and a staleness bound so a page with many interactions and no navigations cannot grow it.
Worth deciding as part of the fix: whether an entry that binds after the navigation span has already ended should still set BROWSER_NAVIGATION_ID on it. The soft-navigation handler at softNavs.ts:131 already treats that attribute as best effort for the same reason.
Notes
Not reproducible locally on an idle machine. The vue-3 soft navigation test passes first try in ~1.7s with retries: 0. Reproducing it needs the main thread contended enough to delay the router guard past the post-click paint.
- Dominant language
- TypeScript
- Stars
- 8.7k
- Forks
- 1.9k
- Avg merge
- 1d 18h
- Merged PRs (30d)
- 562
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
More from getsentry/sentry-javascript
-
Browser Waiting for: Product Owner
Difficulty 2/5 1-3 hours Newbie friendliness 85/100
getsentry/sentry-javascript#24577 · 1 comment ·
-
Task
Difficulty 2/5 1-3 hours Newbie friendliness 78/100
getsentry/sentry-javascript#24558 · 1 comment ·
-
Task
Difficulty 1/5 Under an hour Newbie friendliness 90/100
getsentry/sentry-javascript#24557 · 1 comment ·
-
Task
Difficulty 2/5 1-3 hours Newbie friendliness 84/100
getsentry/sentry-javascript#24556 · 1 comment ·
-
Task
Difficulty 1/5 1-3 hours Newbie friendliness 90/100
getsentry/sentry-javascript#24555 · 1 comment ·
All issues in getsentry/sentry-javascript
Similar issues
-
clawsweeper:linked-pr-open clawsweeper:no-new-fix-pr clawsweeper:source-repro impact:message-loss issue-rating: 🦞 diamond lobster maturity:stable P2
Difficulty 2/5 1-3 hours Newbie friendliness 88/100
-
Difficulty 2/5 1-3 hours Newbie friendliness 84/100
Eynzof/Hermes-CN-Desktop#616 ·
-
ZCode 3.14.3 に対応する Open
Difficulty 2/5 1-3 hours Newbie friendliness 68/100
supermomonga/zcode-acp#24 ·
-
Difficulty 2/5 1-3 hours Newbie friendliness 76/100
growthbook/growthbook#7100 ·
-
triage
Difficulty 1/5 1-3 hours Newbie friendliness 88/100