SSR (server-side rendering): server generates HTML per request, ships fully-rendered page; browser shows content immediately, then hydrates JS. Fast first paint, SEO-friendly, but every request hits server. CSR (client-side rendering): server ships empty shell + JS bundle; browser downloads, parses, executes, then renders. Slower first paint, worse SEO, but lighter server. SSG/ISR are SSR variants where HTML is pre-generated. Pick per page: SSR/SSG for content + SEO, CSR for authenticated apps.
Category
Performance
Bundle size, Core Web Vitals, virtualization, caching.
81 questions
CSR: server ships a near-empty HTML + JS bundle; browser fetches data and renders. Pros: rich interactivity, cheap hosting (static), great for app-shell UIs after first paint. Cons: slow first paint, SEO challenges, bigger JS, blank-screen risk on slow networks. Right for authenticated app dashboards; wrong for content/SEO pages.
CSR: server sends shell + JS; client renders. Fastest TTFB, slowest meaningful paint, weak SEO. SSR: server renders HTML per request; better LCP/SEO, higher origin cost. SSG: pre-rendered at build, served from CDN; fastest globally, but stale until rebuild. Streaming SSR: server flushes HTML in chunks as it's generated — fast FCP + good LCP. RSC: server components ship zero JS for non-interactive parts.
CSR ships JS and renders in the browser (best for app-like, auth'd UI). SSR renders per request on the server (best for personalized, fresh content). SSG pre-renders at build time (best for marketing/blogs). ISR adds incremental revalidation on top of SSG.
CSR renders in the browser (good for dynamic/personalized, bad for first paint/SEO). SSR renders per request on the server (fresh, SEO-friendly, slower TTFB). SSG pre-renders at build time (fastest, cacheable, stale). AI content is dynamic and slow — usually CSR/streaming SSR, not SSG.
All three reinforce each other. SSR ships real HTML so first paint is fast (LCP win) AND crawlers see content (SEO win). SSG goes further: pre-rendered + CDN-cached HTML, sub-100ms TTFB globally, perfect SEO. The Page Experience signal (Core Web Vitals) makes performance a direct SEO factor. Add metadata (title, description, OG, JSON-LD), canonical URLs, sitemap, mobile-first design. Treat SSR/SSG as the foundation — CSR-only for content kills both perf and SEO.
Three Core Web Vitals: LCP (largest content paint — when main content arrives), INP (interaction-to-next-paint — how snappy clicks feel), CLS (cumulative layout shift — how much things jump). Plus TTFB (server response time), FCP (first paint), TBT (Total Blocking Time, Lighthouse-only). Track at p75 from real users. Each has Google thresholds: LCP <2.5s, INP <200ms, CLS <0.1.
Core Web Vitals (LCP, INP, CLS) at p75/p95, segmented by device/network/geo/route. Plus TTFB, FCP, total bundle size, JS error rate, long task count, custom marks for product-critical flows (time-to-search-result, time-to-checkout-ready). Collect via web-vitals.js + RUM, ship to analytics (Datadog/Sentry/internal). Alert on regression rate, not absolute thresholds. Pair lab (Lighthouse CI per PR) with field (RUM) for full picture.
LCP measures loading (largest paint), INP measures interaction responsiveness (replaced FID in 2024), CLS measures layout stability. Optimize each with different levers: LCP via image/critical-resource pipeline, INP via task scheduling, CLS via reserving space.
LCP: ship the hero image fast (CDN, format, priority). INP: keep main-thread tasks short. CLS: reserve space for everything that loads later.
FCP = when the first text/image/SVG paints. Blocked by: slow TTFB, render-blocking CSS, render-blocking synchronous JS in <head>, late-arriving HTML, large HTML. Fixes: CDN + SSR/SSG for fast HTML; inline critical CSS; defer JS; preconnect to third parties; minimize HTML; preload key fonts with font-display: swap.
LCP = when the largest visible content element finishes painting (target < 2.5s). Slowed by: slow server (TTFB), render-blocking CSS/JS, late-discovered hero image, large image bytes, slow networks, client-side rendering. Fixes: fast server/CDN, inline critical CSS, defer non-critical JS, `<link rel='preload'>` the hero, modern image formats + responsive sizes, SSR/SSG.
CLS is a Core Web Vital measuring unexpected layout shifts during the page's life — content jumping as images, ads, or fonts load. Good CLS is ≤ 0.1. Control it by always reserving space: width/height (or aspect-ratio) on media, min-height for async content/ads, font-display strategy + size-adjust to limit FOUT shift, and never inserting content above existing content.
INP measures the worst end-to-end latency from any user interaction to the next paint. Improvements: split long tasks (`< 50ms`), defer non-urgent work (`requestIdleCallback`, React 18 `startTransition`), debounce/throttle handlers, offload to Web Workers, virtualize long lists, avoid layout thrash, and reduce hydration cost (RSC, partial hydration). p75 INP < 200ms is good.
TTFB = time from navigation request to first byte of response. Target: < 200ms cached, < 600ms uncached. Drivers: DNS, TCP/TLS, server response time, redirect chain. Fix: CDN edge caching, geographically close origin, HTTP/2/3, reduce origin work (caching layer, async work moved off the critical path), eliminate redirects, persistent connections.
TBT = sum of (long task duration − 50ms) between FCP and TTI. Measures how long the main thread is blocked, blocking input. Drivers: large JS bundles, heavy parse/execute, long-running event handlers, sync work in effects. Fixes: code split, defer/lazy JS, move heavy work to workers, break long tasks into chunks (yielding to the event loop), debounce work in handlers.
TTI is the moment the page is reliably responsive. The killer is JS — download, parse, execute, hydrate. Reduce by shipping less JS (RSC, code splitting, tree shaking), avoiding long tasks, deferring non-critical work, and hydrating selectively.
Measure with Lighthouse (lab) plus field data (CrUX/RUM), and read the categories: Performance (Core Web Vitals — LCP, CLS, TBT), Accessibility, Best Practices, SEO. Improve by fixing the specific opportunities/diagnostics it lists — but optimize the real experience, not the score.
Lab + field together. Lab: Lighthouse CI in PRs (catches regressions before deploy), WebPageTest for deep dives. Field: web-vitals.js shipped from real users via sendBeacon to analytics; segment by device/network/geography/route. Watch p75 and p95, not averages. Pair with custom marks for product-critical flows. Tie metrics to business KPIs (conversion, bounce) so perf work gets prioritized. Alert on rate-of-change.
Compare metric distributions before and after, not single numbers. Use RUM (web-vitals + analytics) to capture p75/p95 for affected users; segment by route/device/network. Run for at least a week post-deploy to smooth daily/weekly cycles. Pair with lab (Lighthouse CI per PR) for regression catch. Tie to business metrics (conversion, bounce, time-on-task) where possible. Use A/B tests for high-stakes changes.
Translate metrics to user-visible experiences. FCP = 'first time anything appears.' LCP = 'main content appears.' TTI = 'page becomes usable.' INP = 'how snappy clicks feel.' CLS = 'how much things jump around as the page loads.' Tie each to a business outcome (conversion, bounce, NPS), show before/after screenshots or filmstrips, and quantify in dollars when possible. Avoid jargon; lead with the user story.
Webpack starts from entry points, builds a dependency graph by resolving every import, transforms non-JS files through loaders, applies plugins across the lifecycle, then bundles modules into optimized output chunks. Key concepts: entry, output, loaders, plugins, mode, code splitting.
Tree shaking is dead-code elimination over ES modules. Static `import`/`export` syntax lets bundlers analyze the dependency graph and drop exports nothing imports. Side effects (or `sideEffects: false`) decide what's safely removable.
Tree shaking is dead-code elimination enabled by ES module static structure: imports/exports are statically analyzable, so the bundler builds a dependency graph, marks which exports are actually used, and drops the rest. Requires ESM (not CommonJS), and is hindered by side effects — hence `sideEffects: false`.
Code splitting breaks the JS bundle into chunks loaded on demand. Tools: dynamic import() (browser-native), React.lazy + Suspense, Next.js dynamic(). Common splits: per-route, per-modal/heavy widget, per-rarely-used path (CSV export, charts library). Trade-off: smaller initial bundle but extra requests + a moment of loading UI. Pair with preload hints for predictable navigations.
Chunking is the bundler mechanism of dividing output into multiple files (chunks). Code splitting is the strategy/decision of where to split so code loads on demand. Chunking is the 'how', code splitting is the 'why/where' — every code split produces chunks, but the bundler also chunks automatically.
Smaller initial bundle → faster parse/execute → better LCP/INP. Users only download code for the route they hit; rest loads on demand or prefetched on hover. Cuts bytes 30–80% on first paint typically. Tradeoff: cold-load delay when navigating to a non-prefetched route, so prefetch likely-next routes.
Ship less JS upfront so the user can interact sooner. Split by route, by interaction (modals, editors), and by visibility (below-fold). In React, use `React.lazy` + `Suspense` for components and dynamic `import()` for libraries. Preload the next likely chunk on hover/idle to hide the network cost.
Three axes: route-level (each page its own chunk), component-level (heavy widgets behind dynamic import), and vendor (long-lived deps in their own chunk for cache reuse). Combine all three; default to route-level first.
Split by route (lazy-load each page), by component (heavy/below-the-fold/modal components), and by vendor chunks. Use dynamic import() + React.lazy/Suspense, prefetch likely-next chunks, and measure with bundle analysis. Goal: small initial bundle, load the rest on demand.
Split per route (frameworks do this automatically), per heavy widget (modals, editors, charts), per locale, and per feature flag. Tune vendor chunking: don't dump all node_modules into one chunk (single dep change invalidates everything), group by stability and usage. Prefetch likely next routes on hover/viewport. Measure with bundle-analyzer; budget chunks at 30-200KB. Avoid waterfalls (split chunk that requires another split chunk synchronously).
Run a bundle analyzer (webpack-bundle-analyzer, vite-bundle-visualizer, next-bundle-analyzer) to visualize chunk composition. Look for: oversized dependencies (moment, lodash full import), duplicates (multiple React versions), big mega-chunks, unused exports not tree-shaken. Fix by replacing heavy deps, importing per-function (lodash-es), code-splitting routes/widgets, externalizing big polyfills. Set a budget in CI; fail the build when chunks regress.
Measure first, then: tree-shake, route-split, dynamic import heavy widgets, swap heavy deps, ship modern syntax, and budget aggressively.
Resources the browser must download and process before it can render the page — primarily CSS (blocks rendering) and synchronous JS in <head> (blocks parsing). They delay first paint. Fixes: inline critical CSS, defer/async JS, load non-critical CSS lazily, minify, and use font-display.
Critical CSS is the minimal set of styles needed to render above-the-fold content. You inline it directly in <head> so first paint doesn't wait on a network round-trip for the full stylesheet, then load the rest of the CSS asynchronously. Tools (Critical, Penthouse, framework plugins) extract it per route; the trade-off is build complexity and keeping it in sync.
Four <link rel> hints that tell the browser to prepare for upcoming work. preload: fetch a critical resource for the CURRENT page now (high priority). prefetch: fetch a resource likely for the NEXT navigation (low priority, idle). dns-prefetch: just resolve DNS for a host. preconnect: DNS + TCP + TLS to a host. Use sparingly; wrong hints regress perf by stealing bandwidth/connections from the actual page.
Resource hints that tell the browser to prepare for upcoming work. preload: fetch a critical resource for the CURRENT page now. preconnect: open the TCP+TLS connection to a host you'll need (DNS + handshake done in parallel). prefetch: low-priority idle fetch for the NEXT navigation. prerender: render an entire next page in the background (now subsumed by Speculation Rules API). Use sparingly — each one consumes bandwidth and CPU; wrong hints hurt more than help.
Three different intents. preload: 'I need this for the CURRENT page, fetch it now at high priority' (LCP image, fonts). prefetch: 'I MIGHT need this for the NEXT navigation, fetch when idle' (next route's JS). Lazy loading: 'Don't load this until it scrolls into view or is otherwise needed' (offscreen images, dynamic imports). Don't mix them up — they have different priorities and timing.
preload = high-priority current-page resource. prefetch = low-priority future-navigation resource. preconnect = warm up TCP+TLS to an origin. dns-prefetch = resolve DNS only. Use the right one or you waste bandwidth.
Lazy: load when needed. Preload: load now, high priority, current page. Prefetch: load idle-time, low priority, future navigation.
`<script>` (default): parser-blocking. `async`: fetched in parallel, executed ASAP, may interleave with parsing. `defer`: fetched in parallel, executed after parse in order. `type="module"`: defer by default. `modulepreload`: warm the cache for module dependencies. Rule of thumb: `defer` app code, `async` independent (analytics), preload critical, modulepreload module graphs.
Lazy loading defers loading a resource until it's actually needed — usually 'about to be visible.' For images: <img loading='lazy'>. For iframes: <iframe loading='lazy'>. For JS: dynamic import() + React.lazy/Suspense. For arbitrary components: IntersectionObserver to mount when the placeholder enters the viewport. Reduces initial bytes, JS parse cost, and main-thread work; trade-off is the loading state when it finally needs to appear.
Third-party JS (analytics, ads, chat widgets, A/B tools) is usually the worst offender on real-user TTI. Defer everything by default with `async` or `defer`, load post-interaction or on `requestIdleCallback`, sandbox in a Web Worker (Partytown) when feasible, and budget the total third-party weight. Self-host the loader where the vendor allows.
Match strategy to resource: 'public, max-age=31536000, immutable' for hashed assets (forever cache); short max-age + stale-while-revalidate for HTML/API; 'no-store' for personalized/sensitive. Use ETag/If-None-Match for cheap revalidation. Set Vary on responses that legitimately differ. CDN s-maxage for shared caches, private for browser-only. Cache key hygiene matters — wrong key leaks data between users.
Multiple layers: HTTP cache (Cache-Control, ETag) for browser + intermediate caches; CDN (long max-age for hashed assets, short with revalidate for HTML); service worker for offline + custom strategies; in-memory app cache (React Query, Redux); localStorage/IndexedDB for persistent client data. Strategy: 'immutable + far-future' on versioned URLs (hashes), 'stale-while-revalidate' for content, 'no-store' for personalized/sensitive. Cache keys must include anything that changes the response (auth, locale, Vary headers).
CDN basics: edge cache near user, long max-age + immutable for hashed assets, Brotli compression, HTTP/2 or HTTP/3 multiplexing. Image: AVIF/WebP with JPEG fallback, srcset + sizes, on-the-fly resize via CDN image service. JS/CSS: minify, tree-shake, hashed filenames. Fonts: subset, preload, woff2, font-display:swap. Cache key purity: don't accidentally key on cookies/headers. Measure cache hit ratio per resource type and aim for >90%.
Optimize assets: compress, right-size, lazy-load, serve modern formats, use a CDN, cache aggressively. Image compression = reducing file size (lossy drops data, lossless rearranges). JPG: lossy photos. PNG: lossless, transparency, sharp graphics. WebP: modern, smaller than both, supports transparency + animation.
Serve modern formats (AVIF/WebP), correct sizes via srcset, lazy-load below-the-fold, eager + fetchpriority='high' for the LCP image, reserve dimensions to prevent CLS, and use a CDN with on-the-fly resizing.
Serve modern formats (AVIF/WebP), responsive sizes via srcset/sizes, lazy-load offscreen images, prioritize the LCP image, reserve space with width/height or aspect-ratio to prevent CLS, compress and right-size, use a CDN/image service, and consider blur-up placeholders.
Use `<img loading="lazy">` for below-fold, `fetchpriority="high"` for the LCP image, modern formats (AVIF/WebP) via `<picture>` with fallbacks, correctly-sized `srcset` + `sizes`, explicit `width`/`height` (or aspect-ratio) to prevent CLS, and an image CDN that serves the right variant per device. Prefer `<img>` over CSS `background-image` for content imagery.
Ship less CSS (purge unused, split per route, critical-CSS inline + defer rest), keep selectors flat and cheap, avoid render-blocking and @import chains, animate only transform/opacity, use containment (`content-visibility`, `contain`) to limit layout/paint scope, and minify + compress + cache. CSS is render-blocking, so its size and delivery directly affect FCP/LCP.
Reflow (layout) recalculates element geometry — expensive, can cascade. Repaint redraws pixels without changing geometry — cheaper. Layout thrashing is forcing repeated synchronous reflows by interleaving DOM writes and layout reads in a loop. Fix it by batching: read all layout values first, then write all mutations (read/write separation), or use requestAnimationFrame.
The browser pipeline: JS → Style → Layout (reflow) → Paint (repaint) → Composite. Layout is the most expensive; transform/opacity skip layout AND paint and run on the GPU. Avoid layout-thrashing read/write loops.
Reflow = browser recomputes geometry; repaint = re-rasterizes pixels. Batch DOM writes, separate reads from writes (avoid layout thrashing), animate `transform`/`opacity` (composite-only), and use `will-change` / `contain` sparingly to isolate work. Use rAF for visual updates; debounce reads with `getBoundingClientRect` once per frame.
Avoid animating layout properties (width, height, top, left, margin, padding) — they trigger reflow + paint every frame on the main thread. Avoid paint-heavy ones (box-shadow, background, border-radius) when possible. Prefer `transform` and `opacity`: they're compositor-only, GPU-accelerated, and run off the main thread, so they stay at 60fps.
Animating `width`, `top`, `margin`, etc. triggers layout (reflow) and paint on every frame — expensive, runs on the main thread, and janks. `transform` and `opacity` can be handled by the compositor on the GPU: no layout, no paint, just compositing. So they animate at 60fps even under main-thread load. Promote with `will-change`/`transform: translateZ(0)` when needed.
Profile first: record the animation in Chrome DevTools Performance panel (with CPU throttling), look for long frames, purple Layout / green Paint bars. Jank usually means you're animating layout/paint properties. Fix by switching to `transform`/`opacity`, promoting the element with `will-change`, reducing paint area, debouncing JS-driven animation, and respecting `prefers-reduced-motion`.
Debounce = wait until the burst stops, then fire once (e.g., search-as-you-type). Throttle = fire at most once per N ms during a burst (e.g., scroll, drag, resize). For visual updates tied to the screen, prefer `requestAnimationFrame` over a fixed throttle interval.
Debounce delays an action until N ms after the LAST event (waits for the user to stop). Throttle caps the rate (at most one per N ms). Debounce is right for search-as-you-type, autosave, resize handlers. Throttle is right for scroll, drag, mousemove updates. Implement with lodash.debounce/throttle or hand-rolled with setTimeout + clearTimeout. In React, useDeferredValue or use-debounce hook so the debounced value plays nicely with state.
Debounce for 'wait until the user stops' (search-as-you-type); throttle for 'at most once per interval' (scroll, resize). Plus: cancel stale in-flight requests (AbortController), cache/dedupe (React Query), batch, and paginate. Pick the technique by the event's nature.
Drag events fire dozens of times per second per pointer. Three principles: (1) keep the work per event tiny — only mutate transform via requestAnimationFrame, never trigger layout in the handler; (2) use CSS transforms (translate3d) over top/left for compositor-only updates; (3) decouple visual updates from state updates — debounce or batch the React state set, keep the dragging visual purely in CSS. Use the modern Pointer Events + setPointerCapture for unified mouse/touch/pen.
Memoization stops re-renders by giving React's diff stable references and stable child props. `React.memo` skips child renders when props are shallow-equal; `useMemo` caches expensive values; `useCallback` caches function identity. In React 19+, the React Compiler does most of this automatically — manual memoization is a fallback, not the default.
Virtualization (windowing) renders only the visible portion of a long list/grid, keeping DOM size constant regardless of dataset size. The container has a giant inner spacer for scrollbar correctness; only ~20-50 rows mount at any time. Libraries: react-window, TanStack Virtual, react-virtuoso. Trade-offs: a11y (focus, find-in-page), scroll restoration, sticky headers, dynamic item heights. Use for >1000 rows; for smaller lists it's overhead.
When the list is long enough (≈hundreds of rows) that DOM nodes alone hurt — measure first. Virtualization renders only visible rows + overscan, trading complexity for memory and render time.
Same technique as virtualization — render only visible items, not the full N. Container has a giant inner spacer for scrollbar correctness; ~20-50 DOM rows at any time. Libraries: react-window (simple), TanStack Virtual (modern), react-virtuoso (chat / dynamic heights). DOM size becomes constant regardless of dataset. Trade-offs: variable heights, scroll restoration, a11y (focus, Ctrl-F), sticky headers. Use for >1000 items; combine with infinite scroll / cursor pagination for huge datasets.
Infinite scroll = paginated fetching as the user scrolls; virtualization = rendering only the visible window of items even though the data set is in memory. They're orthogonal — combine for huge datasets. Without virtualization, 10k DOM nodes destroy scroll FPS and memory. Virtualization keeps DOM constant (~20 rows) regardless of list size, at cost of complexity (item heights, scroll restoration, accessibility, focus management).
Don't render 10,000 nodes. Virtualize: render only the slice visible in the viewport (plus a small overscan). Use `@tanstack/react-virtual` or `react-window`. Stable keys, memoized row components, fixed or pre-measured heights, and CSS containment to keep paint cheap. Cursor-based pagination on the server side if data is unbounded.
Don't render 5000 DOM nodes. Combine: server-side search/pagination, async incremental load, virtualization (react-window / TanStack Virtual), and a debounced filter input. Most apps need only the last three; large lists need all four.
Don't keep what the user can't see in the DOM. Virtualize long lists (render only the visible window + buffer), lazy-mount below-the-fold sections, use content-visibility: auto, and defer offscreen images with loading=lazy.
Decide where filtering happens: server-side for truly large data (the right answer at scale), client-side only when the dataset is small enough to ship. Client-side opts: debounce input, memoize results, index/precompute, virtualize the rendered list, and consider Web Workers for heavy filtering.
Don't render it all: virtualize the visible window, paginate/infinite-scroll the data. For heavy computation, move it off the main thread (Web Worker) or chunk it across frames; use React's startTransition/useDeferredValue to keep input responsive. Stream and process incrementally.
Don't fetch it all — paginate or stream. On the client: virtualize rendering, normalize and cache, process heavy work in a Web Worker, select only needed fields, and use server-side filtering/sorting/aggregation. Push as much data work to the server as possible.
Three patterns scale better than naive deep-copy snapshots: (1) command pattern — store inverse operations, replay backward; (2) immutable persistent data structures (Immer, Immutable.js) — share unchanged subtrees, only diff allocates; (3) bounded stack with eviction (cap at N entries or M MB, drop oldest). Combine: command-based for size, immutable for correctness, bounded for memory cap. Use Web Worker for serialization/compression of cold history if needed.
Use a caching data layer (React Query/SWR) for dedup, caching, and background refresh; avoid waterfalls by fetching in parallel or co-locating with the router; fetch only what you need; paginate/virtualize large sets; prefetch likely-next data; and apply optimistic updates for mutations.
Optimize in layers: (1) ship less — code split, tree-shake, audit deps; (2) cache more — CDN, HTTP cache, service worker; (3) load smart — preload critical, lazy below-fold, prioritize LCP; (4) format right — AVIF/WebP, Brotli, modern JS; (5) render fast — SSR for first paint, virtualize lists, avoid layout thrash. Measure with real-user metrics (LCP, INP, CLS), fix biggest bottleneck, repeat. Don't micro-optimize what isn't slow.
Performance (loads fast) and responsiveness (feels snappy after load) need different fixes. Performance: SSR/SSG, code split, CDN, image optimization, preload critical, lazy below-fold. Responsiveness: avoid long tasks (>50ms blocks INP), useTransition for non-urgent updates, virtualize lists, debounce inputs, web workers for CPU work, requestAnimationFrame for animations. Measure both — LCP for load, INP for responsiveness — at p75 from real users.
Serve assets from a CDN with edge locations worldwide, render/respond from the edge or regional servers, minimize and split bundles, optimize the critical rendering path, cache aggressively, optimize images, and measure with field data segmented by region.
At scale, perf is layered: network (CDN, HTTP/2, compression, image formats), bundle (code splitting, tree-shake, dependency audit), render (avoid expensive re-renders, memoization where it matters, virtualization for long lists), runtime (debounce inputs, web workers for CPU work), and data (caching, dedup, pagination). Measure first (RUM + Core Web Vitals), find the bottleneck, fix the biggest one, repeat. Don't memoize prematurely; don't micro-optimize what isn't slow.
Real challenges at scale: bundle bloat from N teams contributing, third-party tags eating perf budget, cache invalidation across thousands of edge nodes, regional latency variance, A/B test framework adding render delay, dependency conflicts in monorepos, CI build times growing with codebase, on-call pager-fatigue from JS errors at scale. Fixes are organizational as much as technical: budgets in CI, dependency review, observability investment, error grouping, and forcing experiments behind a perf gate.
Measure first (React DevTools Profiler + Chrome Performance) — don't guess. Common culprits: derived data recomputed on every render, large lists not virtualized, charts re-rendering on unrelated state changes, expensive layout on every filter change, fetching too eagerly. Fixes: memoize derived data, move heavy computation off the render path (worker / server), virtualize lists, debounce filter input, scope React keys/state to avoid wholesale re-renders.
Lighthouse is a synthetic lab test of initial page load. It misses runtime/interaction performance, real-device and real-network variance, post-load JS jank, slow API responses, and the specific user flows that feel slow. Measure with field data (RUM) and profile the actual interactions.