Your React app scores 45 on PageSpeed Insights. You've compressed the images. You've added a CDN. The scores barely moved. That's because Core Web Vitals failures in single page applications have different root causes than failures in static HTML sites — and the standard fixes don't address them.
Why Spas Systematically Fail Core Web Vitals
SPAs fail Core Web Vitals in predictable, structural ways. The root cause: the browser has to download, parse, and execute a JavaScript bundle before it can render meaningful content. On a static HTML page, the browser receives the complete HTML on first request. On an SPA, the browser receives a nearly empty HTML shell and waits for JavaScript to build the DOM client-side.
INP failures follow a similar pattern. React, Angular, and Vue all have architectural features that create long tasks on the main thread that block user interactions. CLS in SPAs comes from dynamic content injection: images loaded without dimensions, ad slots that expand on insertion, elements added after initial render.
The gap widens on real devices. Lab tools like Lighthouse run on a fast machine with a warm cache, so they routinely underreport the JavaScript-execution penalty. Field data from the Chrome UX Report (CrUX) tells the real story: SPAs skew toward worse LCP and INP percentiles precisely because the execution cost is paid by the visitor's device, not the test runner's.
LCP in Spas: Eliminating Render-Blocking Javascript
The most effective fix is server-side rendering (SSR) or static site generation (SSG) for the initial page load. With SSR, the server returns fully-formed HTML including the LCP element in the first response, so the visitor sees content before any framework boots.
If SSR is not immediately feasible: add a preload link for the LCP image in the document head, and use code splitting with dynamic imports to reduce initial bundle size. Prioritize shipping the LCP element's HTML and CSS first; defer everything below the fold.
Two more levers matter. First, self-host and preload your web fonts so text does not wait on a third-party stylesheet — font swap delays are a common hidden LCP cost. Second, choose the LCP element deliberately: a large hero image or the H1 headline, and make sure it renders from server HTML rather than from a client-side fetch. A hero that appears only after an API call can never be a fast LCP.
INP in React and Angular: Reducing Long Tasks
React-specific fixes: Use useTransition and useDeferredValue (React 18) for expensive renders. Use React.memo and useCallback to prevent unnecessary re-renders. Use react-window or tanstack/virtual for long lists. Break large synchronous updates into smaller chunks with scheduler.yield() so the main thread can service input between frames.
Angular-specific fixes: Set ChangeDetectionStrategy.OnPush on components. Use NgZone.runOutsideAngular() for high-frequency events like scroll handlers. Defer non-critical components with Angular's @defer blocks so they do not execute during the initial interaction window.
Across frameworks, the universal fix is to identify long tasks (anything over 50ms) in the Performance panel, then split them. Move non-UI work such as parsing, sorting, and formatting into a Web Worker so it never touches the main thread. Debounce and throttle high-frequency handlers so a single scroll or keystroke does not trigger a cascade of re-renders.
CLS in Spas: Preventing Layout Shifts from Dynamic Content
Every img tag needs width and height attributes, or an aspect-ratio CSS property. Reserve space for dynamically injected content with skeleton placeholders. Fix cookie banners with position:fixed or render them in the initial HTML. Use CSS opacity/transform transitions for route changes instead of height-based animations.
Fonts are a quieter CLS source. If you load a web font without a matching fallback size, the text reflows when the font swaps in. Set a system-font fallback in the same size and metrics (size-adjust) so the swap is invisible. The same applies to embeds and ad slots: declare their dimensions up front, never inject them at auto height.
A Note on Mobile-First Field Data
Core Web Vitals are evaluated on real-user field data, and mobile is where SPAs hurt most. Mid-range Android devices execute JavaScript far more slowly than the laptops developers test on, so the LCP and INP penalties from a heavy bundle are magnified several times over. When you segment CrUX by device, expect the mobile 75th-percentile to be materially worse than desktop for any client-rendered SPA. That gap is the business case for SSR/SSG: it is not about the score on your machine, it is about the experience on the phone in your buyer's hand.
Measuring SPA Core Web Vitals with Field Data
Lab scores lie for SPAs, so measure in the field. Pull the 75th-percentile LCP, INP, and CLS for the actual URL from CrUX or your RUM provider, segmented by device. An SPA that scores 95 in Lighthouse but sits in the needs-improvement band for mobile INP in CrUX is failing real users. Set your alerting on field data, not lab data, and review it monthly as you ship framework changes.
Framework-Specific Quick Reference
For React, the highest-leverage INP fixes are useTransition for marking a state update as non-urgent and useDeferredValue for keeping the input responsive while a heavy list re-renders. Combine them with route-level code splitting so the initial bundle stays small. For Angular, OnPush change detection stops the framework from checking every component on each event, and runOutsideAngular() keeps scroll and resize handlers off the main thread. For Vue, use v-once for static subtrees and shallowRef for large data arrays so a single change does not trigger a deep reactive sweep.
Next.js and Nuxt give you the rendering primitives directly: choose SSG for content that never changes, SSR (or streaming SSR with suspense) for content that must be fresh, and CSR only for the authenticated, behind-login layer where SEO does not apply. The single most reliable architecture for good Core Web Vitals is "render the public page on the server, hydrate later, and keep interactive-only code out of the initial bundle." That one decision resolves most LCP and INP problems before you tune a single component.
A Prioritized Fix Sequence
Fix in this order: (1) move LCP to SSR/SSG or preload so content appears without full hydration; (2) reserve space for every dynamic element to kill CLS; (3) profile and split long tasks to bring INP under 200ms; (4) only then tune fonts, caching, and secondary assets. Each step is independently shippable, so you can show progress without a full rewrite.
Frequently Asked Questions
Why Do Spas Score Worse on Core Web Vitals Than Static Sites?
SPAs require the browser to download and execute a JavaScript bundle before rendering meaningful content, delaying LCP and creating more opportunities for INP and CLS failures.
Does Server-Side Rendering Fix Core Web Vitals for React Apps?
SSR directly addresses LCP by delivering the initial HTML in the first server response. SSR does not automatically fix INP or CLS — those require additional framework-level optimizations.
What Is the Difference Between FID and INP?
FID measured only the first user interaction. INP measures all interactions throughout the session and reports the worst-case percentile. INP replaced FID in March 2024.
Key Takeaways
- SPA CWV failures have structural causes — generic image/caching fixes do not address them
- LCP in SPAs is delayed by JavaScript bundle execution; SSR/SSG and LCP image preloading are the most effective fixes
- INP replaced FID in March 2024 and is more demanding for SPAs
- React's useTransition and useDeferredValue, Angular's OnPush change detection are the primary INP levers
- CLS is almost always caused by images without dimensions or dynamic content injection without reserved space
- Always diagnose using CrUX field data — Lighthouse lab scores consistently underreport SPA CWV failures