Core Web Vitals for Single Page Apps: Fixing INP and CLS in React

A practical guide to understanding and improving Core Web Vitals, with concrete code-level fixes for each metric.

Why SPAs Struggle With Core Web Vitals

Single-page apps ship more JavaScript upfront and re-render content client-side, which creates specific, recurring Core Web Vitals problems that server-rendered sites don’t face in the same way. Here’s how to diagnose and fix the React-specific patterns that cause them.

INP: Diagnosing Slow Interactions

A common culprit in React apps is a large state update triggering an expensive re-render tree on every keystroke or click:

// Problem: every keystroke re-renders the entire filtered list
function ProductSearch({ products }) {
  const [query, setQuery] = useState('');
  const filtered = products.filter(p => p.name.includes(query));

  return (
    <>
      <input value={query} onChange={e => setQuery(e.target.value)} />
      <ProductList products={filtered} />
    </>
  );
}
// Fixed: debounce the expensive filter, keep input responsive
function ProductSearch({ products }) {
  const [query, setQuery] = useState('');
  const [debouncedQuery, setDebouncedQuery] = useState('');

  useEffect(() => {
    const timer = setTimeout(() => setDebouncedQuery(query), 200);
    return () => clearTimeout(timer);
  }, [query]);

  const filtered = useMemo(
    () => products.filter(p => p.name.includes(debouncedQuery)),
    [products, debouncedQuery]
  );

  return (
    <>
      <input value={query} onChange={e => setQuery(e.target.value)} />
      <ProductList products={filtered} />
    </>
  );
}

Breaking Up Long Tasks with startTransition

import { startTransition } from 'react';

function handleFilterChange(value) {
  setInputValue(value); // urgent: keep the input itself responsive
  startTransition(() => {
    setFilteredResults(computeExpensiveFilter(value)); // can be deprioritized
  });
}

CLS: Route Transitions and Lazy-Loaded Components

// Problem: lazy-loaded component pops in without reserved space
const ProductGallery = lazy(() => import('./ProductGallery'));

<Suspense fallback={<div />}>
  <ProductGallery />
</Suspense>
// Fixed: fallback reserves the actual expected space
<Suspense fallback={<div style={{ minHeight: 400 }} />}>
  <ProductGallery />
</Suspense>

Measuring in a React App

import { onINP, onCLS, onLCP } from 'web-vitals';
import { useEffect } from 'react';

function useWebVitals(sendToAnalytics) {
  useEffect(() => {
    onINP(sendToAnalytics);
    onCLS(sendToAnalytics);
    onLCP(sendToAnalytics);
  }, [sendToAnalytics]);
}

Profiling with React DevTools

The Profiler tab shows exactly which components re-render on each interaction and how long they take. Look for components re-rendering that don’t visually change — that’s almost always a missing useMemo, useCallback, or component memoization opportunity.

Conclusion

SPA performance problems are usually about unnecessary re-renders (hurting INP) and unreserved layout space during async loading (hurting CLS). startTransition, memoization, and reserved-space loading states address both without needing to rewrite your app’s architecture.