LCP calculated across navigation with React Router

Hey folks,

We’re investigating some slow LCP scores for pages that should be our fastest. We’ve identified a potential issue with how web vitals are calculated when navigating through a React Router app: The LCP calculation continues across page changes.

Environment details
  • react-router@7.12.0
  • @shopify/shopify-app-react-router@1.1.0
  • @shopify-api@v12.1.2
  • App Bridge and Polaris web components loaded via @shopify/shopify-app-react-router
  • Embedded application

The issue

100% of high LCP values (>5s) are being captured AFTER navigation, indicated by:

  1. Missing FCP data: All 5 events with LCP >5s have null FCP (First Contentful Paint)
  2. Quick navigation: Web vitals events are followed by pageview events within 578-4,319 milliseconds
  3. Pattern breakdown:
    1. High LCP (>5s): 100% missing FCP → post-navigation captures
    2. Medium LCP (2-5s): 40% missing FCP → some post-navigation
    3. Good LCP (<2s): 28.2% missing FCP → mostly accurate

This data is collected through 3rd party tools (Sentry and Posthog), so there is a discrepancy with how Shopify is collecting the data.

What’s happening

Looking at the worst case (22,055ms LCP):

  • Web vitals captured at 15:04:41.596
  • User navigated to /app/feeds at 15:04:42.174 (578ms later)
  • Then to /app/media, /app, and more pages within seconds

The LCP timer kept running in the background and reported 22 seconds - but the user had already moved on. They’re rapidly navigating through the app, which is actually good user behavior, not friction. The LCP calculation stops once they land on a page with a large image.

The real performance

The 39 events with good LCP (<2s) shows the actual page performance, with an LCP of 823ms. This aligns with local testing experience, where if we wait long enough before clicking on a page change, we get the accurate value. If we click fast enough on a link, we see the LCP keep counting.


If you have encountered this problem and solved it, I’d love to hear it. We’ve tried manually sending out pagehide and pageshow events when the page changes, but that doesn’t trigger the LCP to reset.

Do you have a client entry file?

1 Like

@bkspace yes. We use it for Sentry reporting at the moment:

import * as Sentry from '@sentry/react-router';
import { startTransition, StrictMode, useEffect } from 'react';
import { hydrateRoot } from 'react-dom/client';
import { useLocation, useMatches } from 'react-router';
import { HydratedRouter } from 'react-router/dom';

const SENTRY_DSN = import.meta.env.VITE_SENTRY_DSN;
Sentry.init({
  dsn: SENTRY_DSN,
  integrations: [
    Sentry.browserTracingIntegration({
      useEffect,
      useLocation,
      useMatches,
    }),
    Sentry.browserProfilingIntegration(),
  ],
  tracesSampleRate: 0.2,
  profilesSampleRate: 0.2,
});

startTransition(() => {
  hydrateRoot(
    document,
    <StrictMode>
      <HydratedRouter />
    </StrictMode>,
  );
});

@bkspace thanks for pointing me in the right direction :folded_hands:. I can see that removing the client entry file allows properly tracking the events across page loads. I’ll update how we load in Sentry.

For posterity, the following is completely unnecessary in entry.client.tsx and in fact harmful to the LCP calculation:

startTransition(() => {
  hydrateRoot(
    document,
    <StrictMode>
      <HydratedRouter />
    </StrictMode>,
  );
});

I was speaking too soon with my last comment. hydrateRoot is the culprit here, but even without an entry.client.tsx file, the default uses hydrateRoot:

import { startTransition, StrictMode } from 'react';
import { hydrateRoot } from 'react-dom/client';
import { HydratedRouter } from 'react-router/dom';

startTransition(() => {
  hydrateRoot(
    document,
    <StrictMode>
      <HydratedRouter />
    </StrictMode>,
  );
});

The alternative would be to use createRoot, but we lose the SPA capabilities, slowing down the app altogether:

import { startTransition } from 'react';
import { createRoot } from 'react-dom/client';

startTransition(() => {
  createRoot(
    document,
  );
});

I think the ideal solution would be to tell Web Vitals that the page is changing so it can restart its LCP calculation.