Performance Optimization

A React application has two performance stories, and they need different tools. The first is load: how many bytes the browser downloads, parses and executes before anything useful appears. The second is update: how long React takes to re-render when state changes. Sections 2.7 and 2.8 covered the update story — memoization, the React Compiler, transitions and deferred values. This section covers the load story, and the measurements that tell you which story you are in — because the fixes are opposite. A 600 kB bundle is not cured by memo.Every claim here carries a number. The bundle sizes come from a real Vite 8.3.0 build of a small three-route application (React 19.3.0, React Router 8.4.0, Recharts 3.10.1, react-window 2.3.1); the render timings come from that code in headless Chrome 152 on an Intel Core i9-7980XE. Your numbers will differ — that is the point. Tip: Two rules survive every React version: ship the production build, and split the bundle along the routes users actually visit. Code never reached is the cheapest code to optimize away.

2.12.1 Measuring Before Optimizing

The cheapest instrument you own is a counter in a component body and performance.now() around a render. flushSync makes the timing honest: it forces React to finish the render and the commit before it returns, so the number covers the whole pass, not the call that scheduled it.

<div id="root"></div><p id="out" style="font:14px system-ui"></p>

<script src="https://cdn.jsdelivr.net/npm/@babel/standalone@8.0.5/babel.min.js"></script>

<script type="importmap">{ "imports": { "react": "https://esm.sh/react@19.3.0",

"react-dom": "https://esm.sh/react-dom@19.3.0",

"react-dom/client": "https://esm.sh/react-dom@19.3.0/client" } }</script>

<script type="text/babel" data-type="module">

import React from 'react';

import { createRoot } from 'react-dom/client'; import { flushSync } from 'react-dom';

let renders = 0; // the cheapest instrument there is

const Row = ({ n }) => { renders++;

let s = 0; for (let k = 0; k < 2e4; k++) s += Math.sqrt(k + n);

return <li>row {n}: {s.toFixed(0)}</li>; };

const List = ({ q }) => <ul style={{ height: 22, overflow: 'hidden', margin: 0 }}>

{Array.from({ length: 500 }, (_, i) => <Row key={i} n={i + q} />)}</ul>;

const root = createRoot(document.getElementById('root'));

const time = (el) => { const t = performance.now(); flushSync(() => root.render(el));

return (performance.now() - t).toFixed(1); };

const mount = time(<List q={0} />), update = time(<List q={1} />);

out.innerHTML = `mount <b>${mount} ms</b>, update <b>${update} ms</b>, ` +

`${renders} Row renders`;

</script>

Listing 2.96: Timing a mount and an update, and counting renders Figure 2.43: Browser output of Listing 2.96Three facts fall out of one screenshot. The update is cheaper than the mount because React reuses the existing DOM nodes; all 500 rows re-rendered on both passes; and an update of this size has already blown past the 16.7 ms a 60 Hz frame allows. A counter like renders answers the question memoization turns on — is this component re-rendering more often than its inputs change? — in any build.For the load story the instrument is the browser: Lighthouse for Largest Contentful Paint, the Performance panel for Interaction to Next Paint, React DevTools' Profiler tab for ranking components by time in a commit. Run all three against a production build — a development build exaggerates render cost and hides bundle wins.

2.12.2 The Production Build

React ships two copies of itself. The development copy validates props, checks Hook order, warns about missing keys and keeps the bookkeeping React DevTools reads; the production copy strips all of it. In react-dom 19.3.0 the difference is measurable on disk: cjs/react-dom-client.development.js is 1,182,785 bytes, cjs/react-dom-client.production.js is 625,168. Bundlers choose between them by substituting process.env.NODE_ENV at build time and letting minification delete the dead branch — which is why a stray NODE_ENV=development in a deployment script quietly doubles your bundle. Vite chooses for you: npm run build always emits the production copy. npm run build Listing 2.97: Building the sample application for production

vite v8.3.0 building client environment for production...

✓ 668 modules transformed.

dist/index.html 0.32 kB │ gzip: 0.23 kB

dist/assets/index-rk4-OIjP.js 603.28 kB │ gzip: 182.70 kB

(!) Some chunks are larger than 500 kB after minification. Consider:

- Using dynamic import() to code-split the application

✓ built in 274ms

Output of Listing 2.97 npm run preview then serves dist/ over a local static server — the only honest way to check what you are about to deploy, since the dev server transforms modules on demand and never produces these files. Note: React 19 removed the UMD builds, so the old <script src=".../react.production.min.js"> pair is gone. A build-free page loads React as an ES module through an import map, as the demos here do.

2.12.3 Bundling and Minification

Vite 8 builds with Rolldown, a Rust port of Rollup that bundles and minifies in one pass, so there is nothing to configure: npm run build already tree-shakes, minifies and hashes filenames. To see what that pass is worth, rebuild with npx vite build --minify false. Both builds transformed the same 668 modules; the brotli column comes from Node's zlib.brotliCompressSync over the emitted files, which is what a CDN sends. Build Raw gzip brotli vite build 603,289 180,604 151,886 vite build --minify false 1,543,569 329,054 259,318 Reduction 61% 45% 41% Table 2.17: Bytes emitted for one entry chunk, with and without minification (Vite 8.3.0) Minification wins 61% of the raw bytes but only 41% of the transferred bytes, because gzip and brotli already compress repeated identifiers. The raw figure still matters: it is what the engine parses and compiles after decompression. Compress at the edge, not in the build — every serious CDN negotiates brotli.rollup-plugin-visualizer (https://github.com/btd/rollup-plugin-visualizer) (MIT) turns the build metadata into a treemap of which module contributed which bytes. npm i -D rollup-plugin-visualizer, add visualizer({ filename: 'stats.html' }) to plugins, and open the report it writes into the project root; it works unchanged on Rolldown. Here the chart route owns 336 kB of the 603 kB bundle — the number that motivates the rest of this section.

2.12.4 Dynamic import()

A static import is a declaration: the bundler follows it and the code ships whether or not it runs. import() is an expression. It returns a promise for the module namespace object, it can sit inside an if or an event handler, and bundlers treat every one as a split point — the module and everything only it needs move into a chunk fetched on first call and cached afterwards.

const t = performance.now();

const math = await import('./math.mjs'); // one network request, once

console.log(Object.keys(math).join(', '));

console.log('add(16, 26) =', math.add(16, 26));

console.log('loaded in', (performance.now() - t).toFixed(2), 'ms');

Listing 2.98: Loading a module on demand

add, default

add(16, 26) = 42

loaded in 2.95 ms

Output of Listing 2.98 The namespace is a Module exotic object whose keys are the named exports plus default, so a default-exported component is math.default, not math.The specifier must be statically analyzable for a bundler to split on it. import('./routes/' + name + '.js') cannot be resolved at build time, so Rolldown and Rollup bundle every file the pattern could match; Vite's import.meta.glob('./routes/*.jsx') makes that pattern explicit and splittable. Wrap the call in try/catch too: a chunk request fails on a flaky network and, more often, after a redeploy invalidates the hashed filename the page still remembers. Under the hood: import() is resolved by the host, not by V8 — against the importing module's URL and the import map. That is why importing an already-loaded module resolves without a request.

2.12.5 lazy and Suspense Boundaries

lazy turns a dynamic import into a component. You hand it a function returning a promise for a module with a default export; React calls it the first time the component renders, suspends until it settles, then renders the default export and remembers it. Every lazy component needs a <Suspense> boundary above it to own the waiting state — suspending is covered in Section 2.8.5; what code splitting adds is that the thing awaited is an undownloaded script.

import { lazy, Suspense } from 'react';

import ErrorBoundary from './ErrorBoundary.jsx';

const Reports = lazy(() => import('./Reports.jsx')); // module scope, not inline

const Customers = lazy(() => import('./Customers.jsx'));

export default function Panel({ page }) {

return (

<ErrorBoundary fallback={<p>That section failed to load. Reload the page.</p>}>

<Suspense fallback={<p>Loading...</p>}>

{page === 'reports' ? <Reports /> : <Customers />}

</Suspense>

</ErrorBoundary>

);

}

Listing 2.99: Two lazy routes behind one boundary Wrapping the sample application's two heavy routes this way shrank the entry chunk from 603.28 kB to 257.84 kB — 182.70 kB to 81.95 kB gzipped — with the chart moving into a 336.44 kB chunk most visitors never request.Two rules keep this working. Declare lazy components at module scope — calling lazy() inside a component body creates a new component type on every render, which unmounts and refetches the subtree. And pair the boundary with an error boundary (Section 2.11.6): Suspense does not catch rejections, so one dropped download would otherwise unmount the tree.

2.12.6 Route-based Code Splitting

Routes are the natural split points: a visitor on the dashboard needs the dashboard's code and nothing else, and the router knows which module each URL wants. A React Router 8 route object takes lazy either as a function returning a whole route definition or — usually better — as an object whose keys load independently, so a data loader can fetch while the component chunk is still in flight.

import { createBrowserRouter, RouterProvider, Outlet } from 'react-router';

import Home from './Home.jsx';

const Shell = () => <><nav>...</nav><Outlet /></>;

const load = (p) => async () => (await p()).default;

const router = createBrowserRouter([{ path: '/', Component: Shell, children: [

{ index: true, Component: Home },

{ path: 'reports', lazy: { Component: load(() => import('./Reports.jsx')) } },

{ path: 'customers', lazy: { Component: load(() => import('./Customers.jsx')) } },

] }]);

export default () => <RouterProvider router={router} />;

Listing 2.100: Lazy route components with createBrowserRouter

dist/assets/Customers-B1uhhsX8.js 8.81 kB │ gzip: 3.35 kB

dist/assets/index-D4Tnu0Gh.js 312.59 kB │ gzip: 98.65 kB

dist/assets/Reports-C0KU1_ZQ.js 336.44 kB │ gzip: 98.15 kB

Output of Listing 2.100 The entry chunk is 312.59 kB, about 55 kB more than the same split done with <BrowserRouter> and lazy components, because the data router carries loaders, actions and fetchers — a cost you buy back the moment a route pulls in a charting library. path, id, index, caseSensitive and children may not come from a lazy object: the router needs them to match the URL first. Split at the route, not at every component — fifteen 4 kB chunks load more slowly over a high-latency link than one 60 kB chunk. In data mode the router keeps the current page on screen while the next chunk downloads, so there is no fallback flash; read useNavigation().state to show progress instead.

2.12.7 Preloading Resources

Code splitting trades bytes for latency: the chunk is not downloaded until the route is visited, so the first click pays for the round trip. Preloading buys that back by starting the download while the user is still reading the current page. React DOM exposes four hint functions any component may call during render, and deduplicates them, so ten components asking for one URL still emit one tag.

import { preload, preinit, preconnect, prefetchDNS } from 'react-dom';

export default function Page() {

prefetchDNS('https://cdn.example.com');

preconnect('https://api.example.com');

preload('https://example.com/hero.avif', { as: 'image', fetchPriority: 'high' });

preinit('https://example.com/theme.css', { as: 'style' });

console.log([...document.head.children].filter((el) => el.tagName === 'LINK')

.map((el) => el.outerHTML).join('\n'));

return <p>...</p>;

}

Listing 2.101: Resource hints emitted from a component

<link rel="stylesheet" href="https://example.com/theme.css" data-precedence="default">

<link rel="dns-prefetch" href="https://cdn.example.com">

<link rel="preconnect" href="https://api.example.com">

<link rel="preload" href="https://example.com/hero.avif" as="image" fetchpriority="high">

Output of Listing 2.101 The four differ in how far they go. prefetchDNS resolves a hostname, preconnect also completes the TCP and TLS handshake, preload downloads bytes into the cache without running them, and preinit downloads and applies them — which is why the stylesheet came back as an active rel="stylesheet" while the image stayed a rel="preload". preloadModule and preinitModule do the same for ES modules, which is what a chunk is.For route chunks the easiest win is the router's own hint: in React Router's framework mode <Link prefetch="intent"> fetches a route's module and data when the pointer enters the link, and prefetch="viewport" when it scrolls into view. Without a framework, onMouseEnter calling your lazy component's own import() does the same. Hint only the one or two paths people take next: a preload nobody needs competes for bandwidth with one they do.

2.12.8 Windowing Long Lists

A list of 20,000 rows costs 20,000 DOM nodes, and the browser pays for every one in layout, style recalculation and memory long after React has finished. Windowing — virtualization — renders only the rows inside the scrollport plus a small overscan, translating them into position as you scroll. react-window (https://github.com/bvaughn/react-window) 2.3.1 (MIT, npm i react-window) is the smallest option; its version 2 API takes a rowComponent instead of a render-prop child.

<b>plain</b><div id="plain"></div><b>windowed</b><div id="win"></div><p id="out"></p>

<script src="https://cdn.jsdelivr.net/npm/@babel/standalone@8.0.5/babel.min.js"></script>

<script type="importmap">{ "imports": { "react": "https://esm.sh/react@19.3.0",

"react/jsx-runtime": "https://esm.sh/react@19.3.0/jsx-runtime",

"react-dom": "https://esm.sh/react-dom@19.3.0",

"react-dom/client": "https://esm.sh/react-dom@19.3.0/client",

"react-window": "https://esm.sh/react-window@2.3.1?external=react" } }</script>

<script type="text/babel" data-type="module">

import React from 'react';

import { createRoot } from 'react-dom/client'; import { flushSync } from 'react-dom';

import { List } from 'react-window';

const N = 20000, box = { height: 40, font: '13px system-ui' };

const Row = ({ index, style }) => <div style={style}>customer {index}</div>;

const time = (id, el) => { const root = createRoot(document.getElementById(id));

const t = performance.now(); flushSync(() => root.render(el));

return (performance.now() - t).toFixed(1); };

const b = time('win', <List rowComponent={Row} rowCount={N} rowHeight={18}

rowProps={{}} style={box} />);

const a = time('plain', <div style={{ ...box, overflow: 'auto' }}>

{Array.from({ length: N }, (_, i) => <div key={i}>customer {i}</div>)}</div>);

const n = (id) => document.querySelectorAll('#' + id + ' *').length;

out.innerHTML = `plain ${a} ms / ${n('plain')} nodes, windowed ${b} ms / ${n('win')} nodes`;

</script>

Listing 2.102: 20,000 rows, plain and windowed Figure 2.44: Browser output of Listing 2.102The windowed mount is about four times faster, but the node counts differ by three orders of magnitude, and that is what keeps mattering: every later scroll, resize and style change walks the smaller tree. Note the measurement order — mounting the windowed list after the 20,000-node one forces a full-document layout and inflates its time several-fold, a benchmark artifact rather than the library's doing.Windowing has real costs: Ctrl+F finds nothing that is not rendered, and variable row heights need a measurement pass (useDynamicRowHeight). Accessibility is handled for you — spread the ariaAttributes prop List hands each row onto your row element. TanStack Virtual is the headless alternative, react-virtuoso adds dynamic heights and infinite scroll, and react-virtualized is in maintenance mode.

2.12.9 The Profiler Component

<Profiler> puts React's own timers in your hands. Wrap part of the tree, give it an id and an onRender callback, and React calls back after every commit inside that subtree with six arguments: id, phase ("mount", "update" or "nested-update"), actualDuration, baseDuration, startTime and commitTime. The durations are the interesting pair — actualDuration is what this commit cost, baseDuration what it would have cost with no memoization.

<div id="root"></div><pre id="out" style="font:13px Consolas"></pre>

<script src="https://cdn.jsdelivr.net/npm/@babel/standalone@8.0.5/babel.min.js"></script>

<script type="importmap">{ "imports": { "react": "https://esm.sh/react@19.3.0?dev",

"react-dom": "https://esm.sh/react-dom@19.3.0?dev",

"react-dom/client": "https://esm.sh/react-dom@19.3.0/client?dev" } }</script>

<script type="text/babel" data-type="module">

import React, { Profiler, memo } from 'react';

import { createRoot } from 'react-dom/client'; import { flushSync } from 'react-dom';

const onRender = (id, phase, actual, base, start, commit) => { out.textContent +=

`${id} ${phase.padEnd(6)} actual ${actual.toFixed(1)} ms ` +

`base ${base.toFixed(1)} ms commit at ${commit.toFixed(0)} ms\n`; };

const Row = memo(function Row({ n }) {

let s = 0; for (let k = 0; k < 3e4; k++) s += Math.sqrt(k + n);

return <li>row {n}: {s.toFixed(0)}</li>;

});

const Grid = ({ q }) => <ul style={{ height: 20, overflow: 'hidden', margin: 0 }}>

{Array.from({ length: 300 }, (_, i) => <Row key={i} n={i % 2 ? i : i + q} />)}</ul>;

const root = createRoot(document.getElementById('root'));

const draw = (q) => flushSync(() => root.render(

<Profiler id="grid" onRender={onRender}><Grid q={q} /></Profiler>));

draw(0); draw(1);

</script>

Listing 2.103: Reading actualDuration against baseDuration Figure 2.45: Browser output of Listing 2.103Half the rows are memoized against unchanged props, and the second commit shows it: actualDuration lands well under baseDuration, the signal that memoization is earning its keep. When the two are equal nothing is being skipped; when actualDuration stays high across updates that should be cheap, you have found your subtree.The demo loads React's development build on purpose. <Profiler> needs the extra timers, so its callback never fires in a plain production build — for numbers from deployed code use the profiling build (react-dom/profiling, 699,916 bytes against 625,168 for the production client), whose timers add overhead of their own.

2.12.10 React Performance Tracks

Since React 19.2 a development build writes its own timeline into the Chrome DevTools performance panel. Record a profile and two track groups appear beside the browser's own: Scheduler ⚛, with one lane per priority (Blocking, Transition, Suspense, Idle) showing what React was working on and waiting for, and Components ⚛, showing the tree it rendered and how long each part took.The entries are written with console.timeStamp(name, start, end, track, group), the API a page uses to add custom tracks to a Chrome recording — which makes them interceptable, so the demo replaces console.timeStamp before React loads and prints what React would have drawn.

<div id="root"></div><pre id="out" style="font:13px Consolas"></pre>

<script>

window.TS = []; const real = console.timeStamp.bind(console);

console.timeStamp = (n, s, e, track, group) => {

TS.push([group || track, track, n, e - s]); real(n, s, e, track, group); };

</script>

<script src="https://cdn.jsdelivr.net/npm/@babel/standalone@8.0.5/babel.min.js"></script>

<script type="importmap">{ "imports": { "react": "https://esm.sh/react@19.3.0?dev",

"react-dom": "https://esm.sh/react-dom@19.3.0?dev",

"react-dom/client": "https://esm.sh/react-dom@19.3.0/client?dev" } }</script>

<script type="text/babel" data-type="module">

import React from 'react';

import { createRoot } from 'react-dom/client'; import { flushSync } from 'react-dom';

const Row = ({ n }) => { let s = 0;

for (let k = 0; k < 6e5; k++) s += Math.sqrt(k + n);

return <li>row {n}: {s.toFixed(0)}</li>; };

const Table = () => <ul style={{ height: 20, overflow: 'hidden', margin: 0 }}>

{Array.from({ length: 12 }, (_, i) => <Row key={i} n={i} />)}</ul>;

flushSync(() => createRoot(document.getElementById('root')).render(<Table />));

setTimeout(() => { out.textContent = TS

.filter((r) => r[0].startsWith('Scheduler') && r[3] > 0.05)

.map((r) => `${r[0]} / ${r[1]} | ${r[2]} | ${r[3].toFixed(1)} ms`).join('\n') +

`\n${TS.filter((r) => r[1].startsWith('Components')).length} Components entries`; }, 0);

</script>

Listing 2.104: Intercepting what React writes to its performance tracks Figure 2.46: Browser output of Listing 2.104One mount produced Render, Commit and Remaining Effects entries on the Blocking lane — a fourth, Waiting for Paint, shows up whenever the browser cannot paint straight away — plus one Components entry per component. Render dominates, as it should for a tree doing arithmetic; had Commit dominated, the cost would be DOM mutations. That attribution is the value of the tracks. Note: Only the development build emits these entries, and React skips the work entirely unless both console.timeStamp and performance.measure exist — so a browser without them costs you nothing.