Core Web Vitals are a set of three metrics—Largest Contentful Paint (LCP), Interaction to Next Paint (INP), and Cumulative Layout Shift (CLS)—used by Google to quantify loading speed, responsiveness, and visual stability. They were designed around a specific mental model: a browser requests a document, the server returns HTML, and the page loads once. Single-page applications (SPAs) break that model almost immediately, because most of what a user experiences after the initial load happens through client-side JavaScript, not new page requests.
This mismatch is the source of nearly every Core Web Vitals headache SPA teams run into. The metrics still get measured, and they still matter for SEO and user experience, but the tooling and mental models built for multi-page sites need real adjustment before they produce useful numbers for an app built with React, Vue, Angular, or similar frameworks.
This guide walks through each Core Web Vital twice: first through the lens of a beginner’s understanding, then through the more nuanced reality that experienced teams eventually discover.
LCP: Loading Speed in an App That Never “Finishes” Loading
Beginner Understanding
A newcomer to performance work typically treats LCP the same way in an SPA as they would on a static site: find the largest image or text block in the initial viewport, make sure it loads quickly, and move on. The instinct is to optimize the first render—compress the hero image, preload the font, ship less JavaScript—and assume the job is done once that number looks good in Lighthouse.
This isn’t wrong, exactly. It’s incomplete.
Advanced Reality
In a real SPA, the “first” LCP element is often a loading skeleton, a spinner, or an empty shell rendered before any meaningful content exists. The framework hasn’t fetched data yet, hydration hasn’t completed, and the actual largest content element won’t appear until several client-side steps have finished. Lab tools measure LCP against that initial shell, producing a number that looks fast but tells you nothing about when the user actually saw something useful.
Experienced teams address this by focusing on three things instead of one:
- Time to First Byte and initial HTML payload. Server-side rendering (SSR) or static generation for at least the above-the-fold content dramatically changes the baseline LCP, since the browser has real content to paint before JavaScript even executes.
- Hydration cost. A server-rendered page that takes three seconds to become interactive because of a bloated JavaScript bundle hasn’t solved the problem—it has just moved it further down the timeline, where it now shows up as poor INP instead.
- Soft navigation LCP. When a user clicks a link inside the app and the router swaps content without a full page reload, that transition has its own LCP candidate. Google’s Chrome UX Report and newer versions of web-vitals.js now attempt to track these “soft navigation” LCPs separately, and ignoring them means you’re only measuring a fraction of your users’ actual experience.
import { onLCP } from 'web-vitals/attribution';
onLCP((metric) => {
console.log('LCP element:', metric.attribution.element);
console.log('Navigation type:', metric.navigationType);
});
Pay attention to navigationType here—it’s the flag that tells you whether you’re looking at an initial load or a client-side route change, and treating both the same way is a common analysis mistake.
INP: Responsiveness in a World of Virtual DOMs
Beginner Understanding
At the beginner level, INP (Interaction to Next Paint) is usually reduced to “don’t run slow code on click handlers.” Debounce your inputs, avoid heavy synchronous loops, and the metric should stay green. This advice isn’t false, but it underestimates how much of an SPA’s interaction cost is hidden inside the framework itself, not the developer’s own code.
Advanced Reality
Every click, keystroke, or tap in a modern SPA typically triggers a chain of work: an event handler fires, state updates, a virtual DOM diff runs, reconciliation happens, and the browser finally paints the result. Each link in that chain adds latency, and none of it is visible if you’re only profiling your own application logic.
A few patterns account for most of the INP problems teams encounter in production:
- Large state updates triggering full-tree re-renders. A single click that updates a top-level state object can force React (or similar frameworks) to re-render components that have nothing to do with the interaction, inflating the time before the next paint.
- Third-party scripts competing for the main thread. Analytics tags, chat widgets, and ad scripts don’t know or care about your interaction budget, and they frequently run long tasks at the exact moment a user is trying to interact with the page.
- Client-side routing that blocks on data fetching. If clicking a navigation link synchronously waits on a network response before updating any visible state, the perceived responsiveness collapses even though no “slow” code was technically written.
The advanced fix isn’t a single trick—it’s architectural. Techniques like React’s useTransition and startTransition exist specifically to tell the framework which state updates are urgent (must paint immediately) and which are not (can be deferred), giving developers direct control over what used to be an invisible scheduling decision.
import { useTransition } from 'react';
function SearchResults() {
const [isPending, startTransition] = useTransition();
function handleChange(value) {
startTransition(() => {
setSearchQuery(value); // Non-urgent update, won't block input responsiveness
});
}
}
This shifts INP optimization from “write faster code” to “structure updates so the browser can paint sooner,” which is a fundamentally different skill.
CLS: Layout Stability When Content Streams In Over Time
Beginner Understanding
The standard beginner fix for CLS is to add explicit width and height attributes to images and reserve space for ads. On a static page, this solves most of the problem, since the layout is largely fixed once the HTML parses.
Advanced Reality
SPAs stream content in continuously, often long after the initial load. Data fetched asynchronously, conditionally rendered components, and skeleton screens that get swapped for real content all introduce layout shift opportunities that have nothing to do with images.
Some SPA-specific CLS sources are easy to miss:
- Skeleton-to-content swaps with mismatched dimensions. A loading skeleton that’s shorter or taller than the eventual content will cause a shift the moment real data arrives.
- Client-side authentication checks. A page that renders one layout for logged-out users, checks auth state asynchronously, then re-renders a different layout for logged-in users, creates a shift that only affects a subset of visits, making it harder to catch in testing.
- Route transitions without shared layout persistence. If a router unmounts and remounts a shared header or navigation bar on every route change instead of persisting it, each transition can reintroduce shift, even in a part of the page that shouldn’t be moving at all.
The advanced approach treats layout reservation as an ongoing runtime concern, not a one-time markup fix. Reserving space with CSS min-height for async content containers, using content-visibility carefully, and testing CLS across route transitions—not just the initial load—catches issues that a single Lighthouse run on the homepage will never surface.
Measurement: Where the Real Gap Shows Up
| Aspect | Beginner Approach | Advanced Approach |
|---|---|---|
| LCP | Optimize the initial paint only | Track SSR/hydration cost and soft-navigation LCP separately |
| INP | Avoid slow click handlers | Use scheduling APIs (startTransition) to control render priority |
| CLS | Set image dimensions | Audit skeleton-to-content swaps and route transitions |
| Tooling | Lighthouse on page load | Field data (CrUX, RUM) segmented by navigation type |
| Scope | First page load | Entire session, including client-side routing |
This gap is why lab data and field data can tell contradictory stories for the same SPA. Lighthouse measures a single, controlled page load; real users navigate through dozens of soft transitions in a session, each with its own performance profile that never shows up in a synthetic test.
Closing the Loop: A Practical Starting Point
If you’re auditing an SPA’s Core Web Vitals for the first time, don’t start with Lighthouse scores. Start by asking whether your monitoring setup can distinguish between a hard page load and a client-side route change. If it can’t, every number you collect afterward is measuring only part of the picture—usually the easiest part, not the part your users actually experience most often.
What does your current setup tell you about the fifth click of a session, not just the first?
🔗 Recommended Reading
- The Repeat-Visit Paradox: How Service Workers Quietly Reshape Core Web Vitals
- Web Workers vs. Debouncing: The Real Fix for Slow INP Scores
- Edge Computing and Core Web Vitals: What Actually Moves the Needle
- Your DOM Has a Weight Problem: A Step-by-Step Guide to Fixing Excessive DOM Size
- Performance Budgets, Ranked: The 5 Metrics Worth Enforcing (and How to Do It)