The dashboard was green. LCP 1.6 s, INP 84 ms, CLS 0.02, all three comfortably in the good band across the whole property, month after month. The client's own team kept a shared spreadsheet called "slow screens" with eleven entries on it, most of them annotated with a stopwatch estimate somebody had taken on their phone. Both records were accurate. They were describing different halves of the same application.
Core Web Vitals are document metrics. They measure a page from the moment the browser commits a navigation to the moment the largest thing paints and the user first interacts. In a server-rendered site that boundary sits under almost every screen a person visits, so the property-level number is a reasonable summary of the property. In a single-page app it sits under exactly one screen: whichever route the user happened to land on. Everything after that is your router changing the URL with the history API, and no new document means no new LCP.
We stopped putting a property-wide LCP at the top of SPA performance reports this summer. Not because the number is wrong — it is precisely correct about the thing it measures — but because it was being read as an answer to a question it had never been asked. And since Chrome 151 began rolling out at the end of July, there is finally a browser-native way to measure the other thirty-four navigations.
The number describes the entry, not the app
On the dashboard product above we instrumented a week of real sessions before changing anything, counting navigations rather than timing them. The median session contained one document load and thirty-four same-document route transitions. The headline metric on the performance report covered under three percent of the navigation those users actually performed, and it was the three percent we had already optimised hardest, because it was the only part anyone could see.
- Regressions land in the invisible ninety-seven percent. A route that adds a synchronous fetch to its loader gets slower for every user who reaches it, and the property LCP does not move by a millisecond. We have watched a transition go from 900 ms to 4.2 s across three sprints with a fully green vitals dashboard the entire time.
- Effort goes where the light is.Two of the last four SPA audits we ran had a heavily tuned landing route — preloaded fonts, inlined critical CSS, priority hints on the hero — sitting in front of an internal screen that took four seconds to render a table. That is not a team making bad calls. That is a team optimising the only surface they had numbers for.
- Nobody can settle an argument.“The app feels slow” from support against three green metrics from engineering is a dispute with no shared evidence, so it gets resolved by whoever is more senior rather than by whichever route is actually slow.
The failure mode is not missing telemetry. Every one of these teams had RUM. It is telemetry whose unit of measurement stopped matching the product the day the router shipped.

What Chrome 151 actually gives you
Two new performance entry types shipped together, and they are more useful as a pair than either is alone. A soft-navigation entry fires when the browser observes the sequence it treats as a same-document navigation: a user interaction, a URL change, and a DOM modification that follows from it. An interaction-contentful-paint entry reports content painting as a result of an interaction — including content that arrives after an async fetch, which is the shape of essentially every route transition worth complaining about.
Put together, they give you the two timestamps that were previously unobtainable without hand-rolling a router hook and hoping your framework's transition boundary matched what the user perceived: when the navigation began, and when the thing the user was waiting for showed up. Subsequent entries carry the soft navigation's identifier, so you can attribute a paint to a route transition rather than to a page.
Worth being blunt about the caveat, because it decides how you use the data: this is a heuristic, not a contract. It is looking for a pattern that usually means "navigation" and it will be wrong in both directions. A transition your code triggers from a timer or a redirect, with no interaction in front of it, is not a soft navigation as far as the browser is concerned. A tab switch or a filter change that rewrites the URL may well be counted as one even though nobody on your team would call it a navigation. Treat the entries as a strong signal to correlate against your router's own events, not as a replacement for them.
“A metric nobody can attribute to a route is a metric nobody can assign to a sprint. That is the whole reason the slow screens stayed slow for a year.”
The measurement is easy. The naming is the hard part.
Getting the first numbers out of a PerformanceObserver is an afternoon. Making them mean something takes the rest of the week, and it fails on the same two things every time.
The first is cardinality. If you send the raw path, /orders/8f21c4e2-…/items becomes its own dimension and you get a hundred thousand routes with one sample each, which is the same as having no route dimension at all. The fix is to resolve the URL to your router's pattern — /orders/:id/items — in the browser, before anything is sent, because your analytics vendor cannot do it and your framework already knows the answer. Every router we work with exposes the matched pattern; the work is remembering to read it rather than the location.
The second is deciding what counts as arrived. Skeletons make this genuinely ambiguous. A route that paints a shimmering placeholder in 120 ms and its actual table four seconds later will produce a beautiful contentful-paint number and a furious user, and you will have measured the loading state rather than the load. We now pick a completeness marker per route — the row grid, the chart, the invoice total — and record the interval to that, keeping the browser's paint entry alongside as the cheap always-on signal. The two numbers disagreeing is itself the finding: it means the skeleton is doing PR work for a slow query.
Never send the raw location.pathnameto a third party from an authenticated app. We have found order references, invoice numbers, tenant slugs and, once, a password reset token sitting in an analytics vendor’s URL dimension — all of it put there by a performance script somebody added in good faith. Resolve to the route pattern client-side and drop everything else, including the query string.

What we put in the contract now
The practical consequence of all this is that "fast initial load" stopped being acceptance evidence on our SPA builds. A green Lighthouse run on the landing route says the entry is fast. It says nothing about the screen the client's operations team lives in eight hours a day, and that screen is usually the one the money is attached to.
What handover looks like on an SPA now
- Route-level p75 transition latency for the ten highest-traffic routes, not one property-wide number.
- A named completeness marker per route, agreed with the client, so "loaded" is not a matter of opinion.
- Route patterns resolved in the browser — no raw paths, no query strings, no IDs leaving the page.
- Soft-navigation entries correlated against the router's own transition events, since the browser heuristic and your router will disagree on a handful of screens.
- A budget per route in CI, with the ones that regress named in the build output rather than discovered in a spreadsheet a year later.
If you only do one thing this month, count. Instrument nothing, fix nothing, just log how many same-document transitions a real session contains and divide one by that number. That fraction is how much of your application your performance dashboard is currently describing. On every SPA we have measured it has been under five percent, and putting that single figure in front of a client has started more useful conversations than any waterfall we have ever screenshotted.









