Skip to content

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

Description

@isaacs

permalink to CI run showing failure

Existing reports

This is already reported twice, both auto-filed with "Flakiness Type: Other / Unknown" and no diagnosis:

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-3
  • dev-packages/e2e-tests/test-applications/react-router-6
  • dev-packages/e2e-tests/test-applications/react-create-browser-router
  • dev-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:

  • bindInteractionToNavigationSpan records { interactionId, startTime } when there is no pending navigation, instead of discarding the entry.
  • The spanStart handler checks that list against _lastInteractionTimestamp using the existing INTERACTION_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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions