A React application rarely starts slow. It becomes slow gradually — one context provider, one large dependency and one unmemoised list at a time — until users notice typing lag, janky scrolling or a long blank screen on first load.
The good news is that the causes are well understood. The bad news is that guessing at them wastes time. Here is how I approach a slow React application, in the order that usually finds the problem fastest.
First, find out which kind of slow it is
"Slow" means different things, and each has different fixes:
- Slow to load — long blank screen or spinner before anything useful appears. Usually bundle size, network waterfalls or render-blocking resources.
- Slow to respond — clicks and keystrokes lag. Usually too much rendering work on the main thread.
- Slow over time — fine at first, worse after a few minutes. Often a memory leak or ever-growing state.
- Slow with data — fine with ten rows, painful with ten thousand. Usually list rendering or expensive derived calculations.
Ask users or check real-user monitoring before touching code. Fixing load time when the complaint is typing lag helps nobody.
Measure before changing anything
Three tools cover most cases:
- React DevTools Profiler. Record an interaction and see which components rendered, how often and how long each took. Enable "Highlight updates when components render" to see re-renders live.
- Browser Performance panel. Shows long tasks on the main thread, layout thrashing and whether the time goes to scripting, rendering or painting.
- Lighthouse and Core Web Vitals. For load performance: Largest Contentful Paint (LCP), Interaction to Next Paint (INP) and Cumulative Layout Shift (CLS). Field data from real users is more trustworthy than a single lab run.
Keep a baseline number. Without it, you cannot tell whether a change helped.
Cause 1: unnecessary re-renders
When a component's state changes, React re-renders it and, by default, all of its children. That is normally cheap. It becomes expensive when a state change high in the tree re-renders hundreds of components that did not need to change.
Common sources:
- State lifted too high. A search input's value stored in a page-level component re-renders the entire page on every keystroke. Keep state as close as possible to where it is used.
- Context used as a global store. Every consumer of a context re-renders whenever its value changes. One large
AppContextholding user, theme, cart and notifications means a new notification re-renders everything that reads the cart. Split contexts by how often they change, or use a store with selector-based subscriptions. - New object and function identities on each render. Passing
style={{...}}oronClick={() => ...}creates new references every time, which defeatsReact.memoon children.
// Every keystroke re-renders <ProductGrid> with its 500 cards
function ProductsPage() {
const [query, setQuery] = useState('');
return (
<>
<input value={query} onChange={(e) => setQuery(e.target.value)} />
<ProductGrid products={products} />
</>
);
}Moving the input and its state into its own component, or memoising ProductGrid, removes the wasted work. useMemo, useCallback and React.memo are tools for specific, measured problems — not something to sprinkle everywhere. The React Compiler can handle much of this automatically in newer projects, but it does not fix state that lives in the wrong place.
Cause 2: bundle size
Every kilobyte of JavaScript must be downloaded, parsed and executed before the application becomes interactive — and on mid-range phones, parsing and execution often cost more than downloading.
- Run a bundle analyser (for example
rollup-plugin-visualizerfor Vite orwebpack-bundle-analyzer). Large surprises are common: a full date library for one formatted date, an entire icon set, a charting library on pages without charts. - Split by route with
React.lazyand dynamicimport(), so the settings screen is not downloaded on the home page. - Lazy-load heavy, rarely used components — rich-text editors, maps, PDF viewers — when they are opened.
- Prefer libraries that support tree-shaking and import only what you use.
Cause 3: API calls and network waterfalls
A frequent load-time problem is the request waterfall: the page loads, then a component mounts and fetches the user, then a child mounts and fetches their orders, then another child fetches order details. Each request waits for the previous render.
- Start independent requests in parallel, ideally at the route level rather than deep in the tree.
- Use a server-state library that caches and deduplicates requests, so three components asking for the same user trigger one request.
- Ask whether the backend can return what a screen needs in one response.
- Show useful content progressively instead of one full-page spinner.
Cause 4: images and media
Images are often the Largest Contentful Paint element, and unoptimised images are the easiest load-time win available.
- Serve modern formats (WebP/AVIF) at the size they are displayed, using
srcset. - Add
loading="lazy"for below-the-fold images, but not for the hero image — that one should load as early as possible. - Always set
widthandheight(or an aspect ratio) to avoid layout shift.
Cause 5: large lists and expensive calculations
Rendering 5,000 table rows creates 5,000 sets of DOM nodes, regardless of framework. Virtualisation (for example TanStack Virtual or react-window) renders only what is visible and is usually a dramatic improvement.
For expensive derived data — filtering, sorting, grouping large arrays — memoise the result with useMemo keyed on the actual inputs, or move the work to the server. For search-as-you-type, useDeferredValue or debouncing keeps typing responsive while results catch up.
Cause 6: state management that fights React
Some state setups cause broad re-renders by design: a single global store where every component subscribes to the whole state, or derived values recomputed in every consumer. Use selectors that subscribe to the smallest slice needed, and keep server data in a server-state cache rather than a hand-rolled global store.
Cause 7: memory leaks
If the application gets slower the longer it stays open, look for:
- Event listeners, intervals or subscriptions added in
useEffectwithout cleanup. - Caches or arrays that only ever grow.
- Detached DOM nodes still referenced from JavaScript.
Take two heap snapshots in Chrome DevTools a few minutes apart, perform the same actions between them, and compare what grew.
A practical order of work
- Classify the slowness: load, interaction, over time or with data.
- Measure and record a baseline.
- Fix the biggest measured bottleneck only.
- Measure again, then repeat.
- Add guardrails — a bundle-size budget in CI, performance checks on key flows — so the problems do not quietly return.
Performance work is detective work more than optimisation. If your React application has become slow and your team is too busy shipping features to investigate properly, a focused performance audit usually finds the few changes that matter most.