React performance and Core Web Vitals — LCP, INP, CLS, bundle size, lazy loading, code splitting, memoization patterns (useMemo, useCallback, React.memo), image optimization, font optimization, when NOT to optimize. Use when optimizing slow components, debugging performance, building for performance-critical pages (marketing/landing), or auditing Core Web Vitals. Triggers on "performance", "slow", "Core Web Vitals", "LCP", "INP", "CLS", "bundle", "lazy", "memoize", "image optimization".
React performance and Core Web Vitals — LCP, INP, CLS, bundle size, lazy loading, code splitting, memoization patterns (useMemo, useCallback, React.memo), image optimization, font optimization, when NOT to optimize. Use when optimizing slow components, debugging performance, building for performance-critical pages (marketing/landing), or auditing Core Web Vitals. Triggers on "performance", "slow", "Core Web Vitals", "LCP", "INP", "CLS", "bundle", "lazy", "memoize", "image optimization".
react-performance
Senior performance work is mostly not about React. It's about images, fonts, layout shift, and not shipping JS users don't need. Optimize React last.
The performance triage order
When asked to "make this faster", check in this order. The first three usually fix 80%.
Images — are they sized, lazy-loaded below the fold, modern formats?
Fonts — are they self-hosted, preloaded, font-display: swap?
Layout shift — do images/ads have reserved space?
Bundle size — what's in the JS? Code split anything route-bound?
Third-party scripts — what's blocking?
Then look at React-specific concerns (re-renders, expensive components)
Mid-level engineers reach for React.memo first. Senior engineers reach for <Image priority>.
Core Web Vitals (memorize these thresholds)
Metric
What it measures
Good
Needs work
LCP (Largest Contentful Paint)
Time to render the biggest above-fold element
≤ 2.5s
> 4s
INP (Interaction to Next Paint)
Latency on user interactions (replaces FID in 2024)
≤ 200ms
> 500ms
CLS (Cumulative Layout Shift)
How much things visually jump
≤ 0.1
> 0.25
Plus useful supplements:
FCP (First Contentful Paint): first text/image, ≤ 1.8s
TTFB (Time to First Byte): server response, ≤ 0.8s
TBT (Total Blocking Time): main-thread blocked during load, ≤ 200ms
LCP — make the hero render fast
The LCP element is usually the hero image, hero heading, or hero video. Speed it up:
Self-host WOFF2 — eliminates a third-party DNS hop
Variable fonts — one file for all weights
font-display: swap — render text immediately with fallback
Subset to needed characters (Latin only if your audience is Latin)
Render-blocking resources
Move non-critical CSS out of the critical path
Defer third-party scripts: <script defer> or <script async> (defer is usually right)
Inline critical CSS if you're squeezing TTFB
INP — keep interactions snappy
INP is the new FID. It measures the worst event handler delay over a session.
Top INP fixes
Don't do expensive work in event handlers
// Bad — re-filters 10k items on every keystroke, then renders
onChange={e =>setQuery(e.target.value)} // OK// but if filtering happens during render and it's heavy → INP suffers// Fix: useDeferredValueconst deferredQuery = useDeferredValue(query);
const results = useMemo(() =>filterHeavy(items, deferredQuery), [items, deferredQuery]);
Break up long tasks
// Yield to the browser between batchesasyncfunctionprocessChunked(items: Item[]) {
for (let i = 0; i < items.length; i += 100) {
processBatch(items.slice(i, i + 100));
awaitnewPromise(r =>setTimeout(r, 0)); // yields
}
}
Next.js: ANALYZE=true next build (with @next/bundle-analyzer)
Vite: vite-plugin-visualizer
General: source-map-explorer
Common bundle bloat causes
Cause
Fix
Importing the whole lodash
import debounce from 'lodash-es/debounce' (or use native)
Moment.js
Use date-fns (tree-shakable) or Intl.DateTimeFormat
Whole icon library
Per-icon imports or use SVG inline
Bundling polyfills for modern browsers
Set browserslist to "modern"
Client component when server would do
Use Server Components (Next App Router)
Importing dev-only code
Check that process.env.NODE_ENV branches are dead-code-eliminated
Code splitting
// Route-level (Next App Router does this automatically)// Per-page is already split// Component-level lazy loadimport dynamic from'next/dynamic';
constHeavyChart = dynamic(() =>import('./HeavyChart'), {
loading: () =><ChartSkeleton />,
ssr: false, // if it can't SSR
});
// Plain ReactconstHeavyChart = lazy(() =>import('./HeavyChart'));
<Suspensefallback={<ChartSkeleton />}>
<HeavyChart /></Suspense>
Good candidates for code splitting:
Modals / drawers (only loaded when opened)
Below-the-fold sections (use IntersectionObserver to trigger)
Heavy editors / charts / maps
Localized content not on the current path
React-specific perf (only after the above)
When to memoize
useMemo — when:
The computed value is genuinely expensive (filtering 1000+ items, parsing, formatting)
The value is a referenced dependency of another hook that re-runs unnecessarily
The value is passed as a prop to a React.memo'd child
useCallback — when:
The function is a referenced dependency in useEffect / useMemo
The function is passed to a React.memo'd child
The function is in a deeply re-rendering tree
React.memo — when:
The component is rendered many times in a list
The component is expensive AND props are stable
You've profiled and confirmed re-renders are the bottleneck
When NOT to memoize:
Cheap components (a button, a small div)
Components rendered once
Props are inline objects/arrays anyway ({ x: 1 } is new every render → memo useless)
// Often-wrong premature memoconstGreeting = React.memo(({ name }: { name: string }) =><p>Hello {name}</p>);
// Cost: equality check on every render. Benefit: zero, since <p> is cheap.
React 19+ note
React Compiler (when enabled) handles most memoization automatically. If you're on React 19 + Compiler, you can stop manually adding useMemo/useCallback and just let the compiler. Mention this in a walkthrough if relevant.
Common React perf footguns
// Bad — new object every render, defeats child memo
<Child config={{ foo: 1 }} />
// Bad — inline component, remounts every renderfunctionParent() {
functionInner() { return<div />; }
return<Inner />;
}
// Bad — useState init runs every renderconst [data] = useState(expensiveCompute());
// Good — lazy initconst [data] = useState(() =>expensiveCompute());
// Bad — useEffect with object dep, runs every renderuseEffect(() => {…}, [{ id: userId }]);
// Good — primitive depuseEffect(() => {…}, [userId]);
Image optimization checklist
width and height attributes set (or aspect-ratio CSS)
loading="lazy" for below-the-fold
loading="eager" + fetchpriority="high" for the LCP image
Modern format (AVIF / WebP) with JPEG/PNG fallback
Responsive srcset + sizes
decoding="async" (Next/Image does this by default)
alt text (a11y, not perf, but never miss)
Font optimization checklist
Self-hosted WOFF2 (avoid third-party CSS files)
font-display: swap
Subset to needed character ranges
Variable font if you use 3+ weights
<link rel="preload"> for the critical weight
Fallback font with size-adjust to match metrics
Social + brand assets
A separate problem from photos and fonts — these ship in the document head, get crawled by social platforms, and need their own sizing/format rules.
SVG favicons render crisp at any DPI, scale to OS dark mode, and are tiny. Use SVG as primary and only add a PNG/ICO fallback if your audience includes browsers that don't support SVG icons (basically: pre-2019 Edge).
Don't bother with the historical 47-link-tag favicon dance unless you're shipping to a true legacy audience. Modern minimum:
light-dark() is supported on all modern browsers and respects color-scheme: light dark.
OG / Twitter card — the 1200×630 image
Standard dimensions across platforms (Facebook, LinkedIn, Twitter/X, Slack unfurl, iMessage rich link):
1200 × 630 (1.91:1 aspect)
PNG or JPG, max ~5 MB (platform caps), target ~200–500 KB
Critical text in the center 1200×600, since some platforms crop the edges
No fine text — render at 1× and you're done; mobile previews are ~300px wide, so anything smaller than 24px on a 1200×630 canvas is illegible
Generating OG cards via screenshot — system fonts only
A common pattern is rendering an HTML template at 1200×630 and screenshotting it. The trap: web fonts haven't loaded yet when the screenshot fires, so you get FOIT/FOUT artifacts (missing characters, fallback fonts) baked into your card.
Two ways out:
Use system fonts in the OG template only. -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, system-ui, sans-serif. Looks fine in a card, dodges the font-loading race entirely.
await document.fonts.ready before screenshotting if you must use a custom font.
Don't try to inline a @font-face declaration in the OG template — the screenshotter's headless browser still needs to download the font file before rendering.