MENU
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.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>
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.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
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.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');
add, default
add(16, 26) = 42
loaded in 2.95 ms
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>
);
}
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} />;
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
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>;
}
<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">
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>
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>
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>
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.