The React performance checklist I run on every rescue project
Eight checks, in order, that find most React performance problems before anyone says the word "memo".
When a client says “our React app is slow”, the cause is almost never what the team thinks it is. Before touching useMemo, I run the same checklist, in the same order. It finds the real problem nine times out of ten.
1. Measure first
Open the React DevTools Profiler, record the slow interaction, look at the flame graph. No exceptions. Optimizing unmeasured code is guesswork, and guesswork is how teams end up memoizing everything while the actual bottleneck sits untouched.
Two tools cover most of it. The React Profiler tells you which components rendered, how often, and why. The Performance panel in Chrome tells you whether the time is even going to React at all: long tasks, layout thrashing and script evaluation show up there, not in the flame graph. Record the interaction users actually complain about, not the page load in general. A dashboard that loads in two seconds but freezes for 800ms on every filter change has an interaction problem, and that is what INP measures too.
Only when you can point at the expensive frame do you continue down the list.
2. Network waterfalls
Most “render performance” complaints are actually fetching performance: sequential requests where each useEffect waits for the previous one. Fix the waterfall before the renders.
You spot it in the Network tab as a staircase: request two starts only when request one finishes. The usual causes are a useEffect that fetches after the component mounts, inside a component that itself only mounts after a parent’s fetch resolves. Lazy-loaded routes that fetch their own data add another step.
// One waterfall step nobody notices:
const { data: user } = useQuery(['user'], fetchUser);
// This waits for user before it even starts:
const { data: orders } = useQuery(['orders', user?.id], fetchOrders, {
enabled: !!user,
});
The fixes are boring and effective. Start requests in parallel where the second does not truly need the first. Lift fetches up to the route level so they start on navigation, not on mount. If you are on Next.js, this is exactly what server components and route-level data loading are for.
3. Bundle size
Check what is actually shipped. I have found moment.js with all locales, three icon libraries, and an entire charting library imported for one sparkline. Run npx vite-bundle-visualizer or @next/bundle-analyzer: five minutes of work, often a big win.
The usual suspects, in the order I meet them: a date library with every locale bundled (moment is 300+ KB; date-fns or dayjs import only what you use), icon packs imported from the barrel file so tree-shaking gives up, charting libraries loaded on pages that show no chart, and a rich-text editor on every route because it lives in a shared component.
The fix is rarely “rewrite it”. Move heavy dependencies behind a dynamic import() so they load when used. Import icons individually. And check the analyzer again after: bundle fixes have a way of regressing the next time someone adds a package.
4. Unstable references
Objects and arrays created inline in props, context values rebuilt every render. This is where re-render storms start, and it is a code pattern problem rather than a memoization problem.
// Every render creates a new object, so every consumer re-renders:
<UserContext.Provider value={{ user, setUser }}>
The context version is the expensive one, because it re-renders every consumer in the tree at once. Wrap the value in useMemo, or better, split the context: components that only call setUser should not re-render when user changes.
For plain props, fix it at the source instead of wrapping the child in memo. Hoist constant objects out of the component. Derive values where they are used. memo on a child receiving a fresh object every render does nothing except add a comparison that always fails.
5. Giant lists
Anything rendering more than ~200 rows gets virtualized. No discussion.
The DOM cost is real even when React is fast: two thousand rows means tens of thousands of nodes to lay out, paint and hydrate. Users see roughly twenty of them. @tanstack/react-virtual or react-window render only the visible slice and the difference is not subtle, it is the difference between a page that scrolls and one that stutters.
Two cheaper options worth trying first: pagination, when the product allows it, and the CSS content-visibility: auto property for long but simple lists, which lets the browser skip rendering off-screen sections without any JavaScript.
6. Images
Unsized images that cause layout shifts, full-resolution photos in thumbnails. Boring checks, but they pay off.
Every image gets explicit width and height (or aspect-ratio), so the browser reserves space and CLS stays at zero. Everything below the fold gets loading="lazy". The LCP image gets the opposite treatment: fetchpriority="high" and no lazy loading, ever. Serve a srcset so a 400px card does not download a 1600px original, and let your framework or CDN handle AVIF/WebP conversion.
None of this is React-specific, which is exactly why it gets skipped in React codebases. The framework will not save you from a 2 MB hero image.
7. State living too high
A keystroke in a search box shouldn’t re-render the page shell. Push state down to the component that needs it.
The pattern to look for in the Profiler: typing one character lights up half the app. That means the input’s state lives in a layout component, and every keystroke re-renders everything below it. Move the state into the search component and let it communicate results upward, debounced.
Composition solves the rest. A component that takes children does not re-render those children when its own state changes, so an expensive tree passed as children to a stateful wrapper is safe without any memoization. This one change, applied in the right place, regularly removes the need for a dozen memo wrappers.
8. Only now: memoization
If the flame graph still shows expensive re-renders after all of the above, then memo and useMemo become precise tools instead of superstition.
At this point you know exactly which component is expensive and which prop changes without reason, so you can memoize that one path and verify the fix in the Profiler. That is the entire discipline: memoization last, measured before and after. The React Compiler is gradually making this step automatic, which is one more reason not to hand-wrap your codebase in memo today.
The pattern behind the checklist: architecture problems first, micro-optimizations last. Memoization applied to a bad architecture just makes the bad architecture harder to read.
If your React app is slow and you would rather have someone run this list against it, that is a normal engagement for me: a short audit first, then fixes in order of impact. Hire me directly, no agency in between.