← All posts
Deep dive

Your SPA's vitals were invisible. Soft navigations change that.

Open the Core Web Vitals report for a single-page app and most of what your users do isn't in it. The metrics were built around full page loads: one navigation, one document, one LCP. In an SPA the first load is the only real navigation. Clicking into a record, opening a detail pane, switching tabs, paging a table, all of it happens without the browser ever loading a new document, and as far as the measurement pipeline was concerned, none of it existed.

The blind spot worked in your favor for years. A SPA could ship a fast shell, score green on its initial load, and never once answer for how sluggish it felt after you were actually inside it. The 2026 updates close that gap, and the bill lands on exactly the interactions you never had to account for before.

What actually changed

Two things moved, and they compound.

First, Google moved to expand CrUX soft-navigation coverage for single-page applications. A soft navigation is a route change an SPA does in JavaScript: the URL updates and the view swaps, but no document loads. Historically CrUX, the real-user dataset behind your field vitals, keyed off hard navigations, so soft transitions produced no LCP, no CLS attribution, no INP window of their own. As that coverage expands, an SPA's in-app route changes start generating vitals the way a multi-page site's page loads always have, and those transitions are the part most teams have never instrumented.

Second, INP's measurement methodology tightened for input-heavy pages. The three published thresholds didn't move (LCP at 2.5s, INP at 200ms, CLS at 0.1), but how INP is sampled did. The practical effect showed up on re-measurement: sites that reprocessed their field data saw an average 18 to 25 percent regression on p75 versus their late-2025 baseline, with the largest deltas on listing pages, search results, and dashboards. Those three surfaces have something in common. They're the busy, interactive, JavaScript-heavy screens an SPA is made of, the ones you reach through soft navigations.

Put the two together and the shift is blunt: the interactions that used to be invisible are now counted, and they're being counted more strictly, on precisely the pages where SPAs do their real work.

The consequence: you own the transition now

For a server-rendered site, "make the page fast" is a load-time problem: TTFB, critical CSS, image weight. For an SPA, the load is the easy part. The hard part is what happens on the tenth click: the route handler that fetches, the component tree that re-renders, the virtualized list that rebuilds, the main-thread work that blocks the tap the user just made. That's where INP lives, and until now the field data mostly looked past it.

So the work isn't cosmetic. You can't fix a transition you can't see, and you can't defend a number you've never measured. Three things have to be true, and none of them is a dashboard you install and forget.

1. Trend the real-user vitals over time

Field vitals are a lagging signal: CrUX reports a rolling window, so a regression you ship today surfaces in the data weeks later, averaged across your whole traffic. That's fine for confirming a trend and useless for catching the deploy that caused it. The field number stays Google's; what you can own is the moment of change. Scheduled runs drive the same route transitions in real browsers on every deploy, so a transition that got slower fails an assertion, or shows its stall plainly in the trace, the night it ships rather than weeks later in a rolling average. And server-side production telemetry, trending CPU, memory, sessions, and errors over time, catches the backend shift that so often rides along with a front-end regression, in a chart you own on your schedule instead of Google's.

2. Test the transitions themselves

A vitals chart tells you a transition got slower. It doesn't tell you the transition still works. Those are different failures and you need both covered, because a soft navigation that renders the wrong destination state is a correctness bug the field metric will happily score as fast.

This is where SPA testing quietly separates the honest tests from the flaky ones. A soft navigation has no page-load event to wait on. Click a link and Playwright's waitForNavigation resolves against nothing, because the browser never navigated. The only reliable signal that the transition finished is the destination state appearing in the DOM. So you assert on that, and you let the assertion do the waiting:

// Fragile: there's no navigation event in an SPA
await page.getByRole('link', { name: 'Invoices' }).click();
await page.waitForNavigation(); // resolves against nothing

// Honest: wait for the destination state to actually render
await page.getByRole('link', { name: 'Invoices' }).click();
await expect(page).toHaveURL(/\/invoices/);
await expect(page.getByRole('heading', { name: 'Invoices' })).toBeVisible();

That auto-retrying expect is the whole trick. It re-samples the DOM until the destination is really there or it times out, which is exactly the discipline soft navigations demand, since the render lands whenever the fetch and re-render happen to finish rather than at a fixed moment. Lean on web-first assertions and a transition test waits for the truth instead of guessing a duration; reach for a waitForTimeout and you've written a test that flakes the day the route fetch runs 200ms slower. Those SPA-specific patterns for client routing, hydration, and virtualized lists cover the same failure modes the new vitals now put a number on, so the tests you write to keep transitions correct and the metrics you watch to keep them fast finally point at the same surface.

3. Load-test the browser-experienced journey

INP problems concentrate under concurrency, which is the part teams miss until it's live. A route transition that resolves in 120ms on your idle laptop can stretch well past 200ms once the same box is serving a thousand other sessions, because the main-thread contention, the slower API responses, and the garbage-collection pauses all arrive at once. A protocol-level load test won't catch it; it fires HTTP requests and never renders a page, so it's blind to the exact client-side work INP measures. You need a real browser under the load. Running real-browser virtual users that replay the actual journey as concurrent users is what surfaces an interaction that's fine at rest and janky at scale, before the field data (and your users) find it for you.

The through-line

The 2026 changes didn't invent SPA slowness. They made it visible and started scoring it strictly, on the transitions you were free to ignore for years. The fix here is unglamorous: the same testing loop you already run, aimed one layer deeper. Trend the real vitals so a regression surfaces as a step in a chart. Test each transition with assertions that wait for the destination state, so "it navigated" actually means the right screen rendered. Load-test the journey in a real browser, because that's the only place INP under concurrency shows itself. Get those three pointed at the same route change, and when the metric goes green it's the tenth click that earned it, not the first paint.

Try it against your own app

TestVibe generates real Playwright tests from plain-language descriptions of the transitions you care about, verifies them against a live run before you rely on them, and pairs that with server-side production telemetry and browser-VU load tests so the same route change is covered for correctness, watched in production, and pressure-tested under concurrency. Get early access, or read how testing React SPAs handles the failure modes the new vitals now measure.

Early access

Ready for tests that write themselves?