Measure first, then cut work in this order: ship less JavaScript (code splitting, lighter dependencies), render less (state kept low, virtualized lists, memoization where the Profiler shows waste) and keep the main thread free for input (transitions, deferred values).
Measure: the React DevTools Profiler for renders, the Performance panel for long tasks, Lighthouse and field Core Web Vitals for what users feel.
Loading: route-level lazy, a small entry chunk, server rendering or prerendering for content pages, images with explicit sizes.
Rendering: keep fast-changing state in the component that uses it, pass expensive subtrees as children, virtualize long lists, use stable keys.
Memoization: memo, useMemo and useCallback by hand, or the React Compiler inserting them automatically.
Responsiveness: useTransition and useDeferredValue for non-urgent updates, Web Workers for heavy computation.
Data: cache server state (TanStack Query), avoid request waterfalls, prefetch on hover or focus.
"Wrap everything in useMemo" is the weak answer; the strong one starts with a profile and fixes the biggest cost.
ReactSenior
Performance
React.memo, useMemo and useCallback, React Compiler, code splitting and lazy, list virtualization, the Profiler, Core Web Vitals, bundle size
Questions closed
0 / 20
- 0Review
- 8Asked often
Questions
React.memo wraps a component so React skips re-rendering it when the parent re-renders with props that are shallowly equal (Object.is per prop) to the previous ones. It still re-renders when its own state changes or a context it reads changes.
Shallow comparison: an object, array or arrow function created in the parent's render is a new reference every time, so memo sees a changed prop and renders anyway.
Custom comparison: the second argument arePropsEqual(prev, next) replaces the default check; it must compare every prop, including functions, or the component shows stale data.
**children**: JSX passed as children is a new element on every parent render, so a memoized component that takes children rarely skips.
A hint, not a guarantee: correctness must never depend on the skip.
With the React Compiler enabled, most manual memo calls become unnecessary because the compiler memoizes the JSX itself.
Dashboard creates a new options object and a new onPointClick arrow function on every render, so the shallow comparison in memo always sees changed props and never skips.
Every keystroke sets query, which re-renders Dashboard; { ... } and () => ... written in JSX are fresh references each time.
useMemo keeps options the same object until data changes, and useCallback does the same for the handler.
A structural fix works too: move the input and its state into a SearchBox component, so typing never re-renders Dashboard at all.
With the React Compiler on, the original code is memoized automatically and the problem disappears.
Memoization trades CPU for memory and complexity: every render still runs the prop or dependency comparison and keeps the old value alive. It pays off only when the skipped work is bigger than that, and it silently stops working when one input is a new reference on every render.
Comparison is not free: memo compares every prop on every parent render; for a component that renders in microseconds, the check is pure overhead.
Broken chains: one unmemoized object or callback prop defeats memo, so a half-memoized tree pays the cost and gets nothing back.
Memory: cached values and closures stay alive until their dependencies change, which matters for large derived arrays.
Bugs: a wrong dependency array gives stale values; a custom arePropsEqual that ignores a function prop calls a stale callback.
Readability: wrapping everything hides the few places where memoization actually matters.
Profile first, fix the structure (move state down, pass children) before memoizing, and with the React Compiler write plain code and let it memoize.
A state update re-renders only the component that owns the state and its descendants, so keep fast-changing state in the smallest component that needs it. Elements passed in as children are created by the parent, so they are not re-rendered when the wrapper's own state changes.
Move state down: if only an input uses query, keep query in a SearchBox instead of the page; typing then re-renders the box, not the page.
Lift content up: when a wrapper needs state (scroll position, hover, an open flag) but wraps an expensive subtree, take the subtree as children. The element objects are the same references as last time, so React bails out of them.
Both techniques need no dependency arrays and do not break when someone adds an inline callback.
Try them before memo; reach for memoization when the structure cannot change.
The React Compiler (stable since 1.0 in October 2025) is a build-time Babel plugin that analyses components and hooks and inserts fine-grained memoization automatically. For new code it replaces most manual useMemo, useCallback and memo; they stay as escape hatches.
How it works
It rewrites each component to cache JSX, derived values and callbacks per input, more granularly than hand-written useMemo, and it can memoize code after an early return, where hooks cannot go.
It ships as babel-plugin-react-compiler; Next.js (reactCompiler: true), Vite and Expo turn it on with a config option. React 17 and 18 also need react-compiler-runtime.
What it requires
Code must follow the Rules of React: pure render, no mutation of props or state during render, hooks called unconditionally. Where it cannot prove a component safe, it leaves that component uncompiled.
eslint-plugin-react-hooks ships the compiler's diagnostics as lint rules, so violations show up in the editor.
A "use no memo" directive at the top of a function opts that component out while you debug it.
What stays manual
Keep existing useMemo or useCallback where the value is an effect dependency, since changing its memoization can change when the effect fires.
It does not fix structural problems: a list of 10,000 rows still needs virtualization, and a heavy bundle still needs splitting.
Record the slow interaction in the React DevTools Profiler, then read each commit's flame graph: grey bars did not render, coloured ones did, with the time each took. Turn on "Record why each component rendered while profiling" to see the cause: props, state, hooks, context or a parent render.
Flame graph: one commit at a time; bar width is render time including children, so look for wide yellow bars.
Ranked chart: the same commit sorted by the component's own time; the top entries are where to start.
Commit bar (top right): tall bars are slow commits; click one to jump to it.
"Highlight updates when components render" flashes re-rendering components live, the quickest way to spot an input that re-renders the whole page.
Profile a production build (the react-dom/profiling entry) for real numbers; development mode is several times slower.
React 19.2 adds Performance Tracks to the Chrome DevTools Performance panel, showing scheduler priorities and component renders next to network and JavaScript.
In code, <Profiler id onRender> reports actualDuration and baseDuration for each commit, useful for logging slow screens.
Code splitting breaks the bundle into chunks that load when needed instead of up front. React.lazy(() => import('./Settings')) turns a dynamic import into a component: its first render suspends while the chunk downloads, and the nearest Suspense shows its fallback.
The bundler (Vite, Rollup, webpack) sees import() and emits a separate chunk; the module needs a default export, or you map it: lazy(() => import('./x').then((m) => ({ default: m.X }))).
Split by route first: each page is a natural boundary; React Router v7 (lazy on a route) and Next.js do it for you.
Then split heavy, rarely used widgets: rich text editors, charts, maps, admin screens.
A chunk that fails to load (a deploy replaced the files) throws, so put an error boundary around lazy parts and offer a reload.
Preload on intent: call the same import() on hover or focus so the chunk is cached by the time the user clicks.
Do not split tiny components: every chunk is an extra request and a fallback flash.
lazy is called inside the component, so every render creates a new component type. React sees a different type at the same position, unmounts the old Editor together with its state and suspends again on the new one.
lazy must run once, at module level, so the component type and its loaded module are reused across renders.
Clicking save calls setSaved, which re-renders NotePage and triggers the reset.
The same rule applies to any component defined inside another component's body: a new function on every render means a remount on every render.
The fix moves the lazy call to the top of the module; nothing else changes.
Dashboard also imports CHART_COLORS statically from ./RevenueChart, so that module, with the chart library it imports, has to be in the same chunk as Dashboard. A dynamic import() cannot move a module that is statically imported elsewhere.
Vite and Rollup warn about it at build time: the module "is dynamically imported by ... but also statically imported by ..., dynamic import will not move module into another chunk".
One shared constant, helper or re-export from a barrel file is enough to pull the whole module back in.
Fix: move what both sides need into a small module (chartColors.ts) that does not import the library.
Type-only imports (import type) are erased and do not cause this.
Check the result with a bundle visualizer, not only by looking at the code.
Virtualization renders only the rows visible in the scroll viewport plus a small overscan, positioned inside a spacer as tall as the full list. A list of 50,000 rows then costs React and the DOM a few dozen rows.
When: hundreds to thousands of non-trivial rows, chat history, data grids, infinite feeds; under about a hundred simple rows it is not worth the complexity.
How: on scroll, compute the first and last visible index from scrollTop and the row sizes, render that slice and offset it with transform or top.
Libraries: TanStack Virtual (useVirtualizer, headless, measures dynamic sizes), react-window, react-virtuoso (variable heights and reverse chat lists out of the box).
Costs: browser find-in-page and screen readers do not see off-screen rows; variable heights need measuring; keep row keys stable and rows cheap.
CSS alternative: content-visibility: auto lets the browser skip layout and paint for off-screen sections while keeping them in the DOM.
Core Web Vitals are Google's three field metrics, judged at the 75th percentile of real visits: LCP (main content shown, good under 2.5 s), INP (response to input, good under 200 ms, replaced FID in 2024) and CLS (layout stability, good under 0.1).
LCP
A client-rendered SPA paints nothing useful until the bundle downloads, runs and fetches data; SSR or prerendering, a smaller entry chunk and fetchpriority="high" on the hero image help.
Lazy-loading the hero image, or inserting it from JavaScript, delays LCP.
INP
Long tasks on the main thread: a large synchronous re-render after a click or keystroke, heavy effects, third-party scripts.
Fix with smaller re-renders (state kept low, memo, virtualization), transitions for non-urgent updates and splitting long work.
CLS
Images without width and height or aspect-ratio, content inserted above existing content after data loads, web fonts that swap with different metrics.
Reserve space with skeletons of the final size, and use font-display with fallback font metric overrides.
They announce requests before the browser would find them: preload fetches a file the current page needs, prefetch fetches one the next navigation may need at idle priority, and preconnect opens a connection to an origin early. React 19 adds react-dom functions for most of them.
**preload** is for critical files found late: a font referenced from CSS or an LCP image set as a CSS background. A wrong or missing as, or no crossorigin on a font, downloads the file twice.
**prefetch fills the HTTP cache for the next page without competing with the current one.
preconnect saves the DNS, TCP and TLS round trips to a third-party origin (an API, an image CDN). Keep it to a few origins: unused connections cost CPU and are dropped after about 10 seconds.
React 19**: preconnect, prefetchDNS, preload, preloadModule, and preinit(href, { as: 'script' | 'style' }), which also runs the script or applies the stylesheet. There is no function for prefetch: use the <link> element or the router's prefetching. Call them during render, in Server or Client Components, or in an event handler; React deduplicates them by href.
Why functions: a component deep in the tree declares what it needs, and during SSR React writes the hints into the HTML stream, so the downloads start early.
Overuse backfires: a preload is a mandatory fetch that competes for bandwidth, so ten of them delay the LCP image. A preloaded image keeps low priority unless you add fetchPriority: 'high', as the code does for the banner.
Show text in a fallback font at once with font-display: swap (or optional), get the font file early by self-hosting a small WOFF2 and preloading it, and adjust the fallback's metrics with size-adjust so the swap does not move the text.
Invisible text: browsers hide text while the font downloads (up to 3 seconds in Chrome and Firefox). swap shows the fallback at once; optional gives the font about 100 ms, then keeps the fallback for that page view: no swap and no shift, but the first visit may not use the font.
Layout shift: a @font-face for the fallback with size-adjust, ascent-override, descent-override and line-gap-override makes it take the web font's space. Tools such as Fontaine or Capsize compute the values, and next/font does it automatically.
Preload the one or two files used above the fold, since a font is requested only after the CSS is parsed. crossorigin is required even on your own domain, because fonts are fetched in CORS mode; without it the file downloads twice.
Self-host: Google Fonts adds two more origins to connect to, and browsers now partition the HTTP cache by site (Chrome since 86), so a copy cached for another site no longer helps.
Fewer bytes: WOFF2 only, a variable font instead of one file per weight, and unicode-range subsets, so an English page never downloads the Cyrillic file.
When pure computation blocks the main thread for more than about 50 ms: parsing a large CSV or JSON, sorting or filtering tens of thousands of rows, building a search index, processing an image. The component sends the input with postMessage and sets state when the result message arrives.
What it cannot do: a worker has no DOM, no React and no window. It helps when the cost is computation; slow rendering of many components needs virtualization or a transition instead.
Messages are copies: structured clone takes time for big objects and drops functions and class prototypes. Transfer an ArrayBuffer (postMessage(buf, [buf])) to move it without copying.
Creating one: Vite and webpack 5 bundle new Worker(new URL('./sort.worker.ts', import.meta.url)) as a separate chunk; it must be this URL form, and Vite's docs add { type: 'module' }.
Comlink turns messages into promise calls: expose(api) in the worker, await wrap<Api>(worker).sort(rows) in the app.
Lifecycle: create the worker in an effect and terminate() it in the cleanup. For frequent inputs keep one worker and tag each request with an id, so a slow old answer cannot overwrite a newer one.
Measure first: startup and copying cost a few milliseconds, so a 5 ms job stays faster on the main thread.
INP measures the delay from an input to the next paint, and it is poor when a long task blocks the main thread: usually a large synchronous re-render, heavy work in the event handler or third-party scripts. The fix is to paint the urgent feedback first and move the rest out of the way.
Urgent first: update the pressed button or the input immediately and wrap the expensive state update in startTransition; React renders it in interruptible slices and yields to new input about every 5 ms.
Defer derived UI: useDeferredValue renders a slow child with the previous value first and the new one in the background.
Yield in handlers: split long non-React work with await scheduler.yield() (Chromium; fall back to setTimeout) so the browser can paint between chunks.
Move it off the thread: parsing, sorting or diffing large data belongs in a Web Worker; React only renders the result.
Effects count too: effects caused by a click run before the browser paints, so heavy effect code adds to INP.
Render less: a transition still does all the work; virtualization and memoization reduce it.
<Activity mode="hidden"> hides a subtree without unmounting it: React keeps its state and DOM, cleans up its effects and renders updates to it at the lowest priority. Switching back to visible is instant and keeps scroll position, typed input and loaded data.
Back navigation: keep the previous tab or screen hidden instead of unmounted, so returning to it does not refetch or render from scratch.
Pre-rendering: render a likely next screen in hidden mode; its code and Suspense-aware data load in the background.
Effects: hidden children run their effect cleanups and re-run effects when shown again, so subscriptions and timers stop while hidden.
Cost: hidden trees still hold memory and DOM nodes, so keep a few, not every screen ever visited.
Before 19.2 the choice was conditional rendering (state lost) or CSS display: none (state kept, but effects keep running).
Collect real-user metrics (RUM): the web-vitals library reports LCP, INP and CLS from each visitor's browser, you send them with navigator.sendBeacon to an analytics endpoint and watch the 75th percentile per page and device class.
Lab vs field: Lighthouse and the DevTools Performance panel are repeatable but run on one fast machine; field data shows the slow Android phones and bad networks.
Attribution: the web-vitals/attribution build reports which element was the LCP and which interaction and script caused a slow INP.
CrUX (Chrome UX Report) and Search Console show the same metrics for public sites without your own pipeline, over a 28-day window.
React-specific: <Profiler onRender> in a profiling build, or a PerformanceObserver for long tasks, can tag slow commits by screen.
Budgets: fail CI on entry-bundle growth, because field metrics show a regression only days after the release.
Measure with a bundle visualizer first (rollup-plugin-visualizer for Vite, webpack-bundle-analyzer, or source-map-explorer on the built source maps), then cut the biggest items: heavy dependencies, code that belongs in lazy chunks and code that tree shaking failed to remove.
Heavy dependencies: replace moment with date-fns or Intl, import lodash per function or use native methods; check a package's cost before adding it.
Tree shaking needs ES modules and side-effect-free packages ("sideEffects": false); barrel files that re-export everything and CommonJS packages often defeat it.
Split: route-level lazy, heavy widgets on demand, import() for libraries used only after an action (PDF export, charts).
Ship modern JavaScript: target modern browsers (build.target in Vite) instead of transpiling and polyfilling for old ones.
Guard it: a size budget in CI (size-limit or a script on gzip sizes) stops regressions in review rather than in production.
Judge gzip or brotli size and parse and execute time on a mid-range phone, not bytes on disk.
Reproduce it with CPU throttling, record the keystroke in the React Profiler and the Performance panel to see whether the time goes to React rendering, layout or your own code, then fix the biggest cost, which is usually rendering all 5,000 rows on every key press.
Diagnose
Profiler with "why did this render": does the whole table re-render, and does every row?
Performance panel at 4x CPU throttling: long tasks, and time split between scripting and style or layout.
The filter itself: per-row work on each key (parsing dates, calling toLowerCase on every field) multiplies by 5,000.
Fix, biggest win first
Render fewer rows: virtualize the table, so a keystroke renders about 30 rows.
Keep typing urgent: hold the input's state outside the table's parent and render rows from useDeferredValue(query).
Skip unchanged rows: memo the row component, pass stable props, key rows by id, never by index.
Cheaper filtering: precompute normalized search keys once with useMemo; debounce if filtering calls the server.
Verify
Record again and compare commit duration and INP; for a critical screen, add a performance check so the fix does not regress.