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.