“How do you optimize a slow React app?” is deceptively open-ended. Answer without structure and you’ll spend five minutes on memoization, forget to mention network or memory, and leave the interviewer unsure whether you have a repeatable process or just a collection of tricks.
The framework that works is seven distinct problem areas, each with different root causes, different debugging tools, and different solutions. The right answer to a performance question starts with identifying which area is the actual bottleneck, because optimizing re-renders when the problem is network is wasted effort, and chasing bundle size when the tab is leaking memory doesn’t move the needle.
What this covers: A complete framework for answering frontend performance questions in interviews, organized as seven areas that mirror how performance problems actually present in production code.
The framing I always start with
Before I get into any specific technique, I say something like this:
“I approach performance as seven distinct problem areas. Each has different root causes, different debugging tools, and different solutions. When I join a slow app, the first thing I do is identify which area is actually the bottleneck, because optimizing rendering when the problem is network is a waste of time.”
That sentence alone signals structured thinking. Interviewers are pattern-matching for “does this person have a repeatable process, or do they just know individual tricks?”
Then I walk through the seven areas. Here’s exactly how I explain each one.
How measurement ties the seven buckets together
Interviewers often like hearing that you classify before you optimize. I map symptoms to buckets so I do not “fix” re-renders when the real problem is LCP, or chase bundle size when the tab is leaking memory.
| If the symptom looks like… | I look here first |
|---|---|
| Slow first paint, blank screen, huge JS | Build (#5), assets (#6), network (#3) |
| Janky scroll, input lag, stuttering animations | Main thread, frame budget, long tasks, re-renders (#2), sometimes huge DOM (#1, #4) |
| Fine in Lighthouse, bad on a cheap phone | Mobile DOM and paint cost (#4), images/fonts (#6) |
| Fine for five minutes, awful after an hour | Long-session memory (#7) |
That table is not a script—it is a debugging compass. In a real job I confirm with DevTools (Performance, Network, Memory) or RUM. On the blog I wrote up the underlying models here:
- The browser’s main thread and rendering pipeline: what actually runs per frame
- The ~16.6 ms frame budget at 60 Hz: why “fast load” and “smooth runtime” are different problems
- Core Web Vitals and Lighthouse: what LCP, CLS, and INP reward and what they miss
- Long tasks and main-thread blocking: why INP and “jank” often trace to the same root cause
- Performance hub: quick “how / why” reference: layout thrashing,
will-change, passive listeners, and other one-liners you can name-check in an interview
1. Rendering large datasets
The situation: You have a list, table, or feed with thousands of rows (an order book, a data grid, an activity log). You render them all and the browser slows to a crawl.
How I explain the trade-offs:
The first thing I do is separate the three approaches, because teams often conflate them:
| Approach | What it does | Best for |
|---|---|---|
| Pagination | Renders only one page of data at a time; user explicitly navigates | Read-heavy tables, URL-addressable records, SEO content |
| Infinite scroll | Appends more items as user scrolls; old items stay in DOM | Social feeds, content discovery, low-importance archives |
| Virtualization | Only renders viewport rows; DOM count stays constant regardless of data size | Real-time dashboards, large data grids, performance-critical lists |
I always point out what most candidates miss: infinite scroll has a hidden DOM growth problem. Every appended batch stays in the DOM. After 50 batches, you have the same performance problem as rendering everything up front — just delayed by a few minutes of scrolling. I’ve seen “infinite scroll” ship as a performance fix that actually made things worse.
Virtualization is the real answer for truly large datasets. Libraries like react-window and TanStack Virtual render only the rows inside (or near) the viewport, keeping the DOM node count constant at ~20-50 nodes regardless of whether the dataset has 500 or 500,000 items.
The trade-offs worth mentioning in an interview: variable-height rows are harder to virtualize (you need a measurement strategy), screen readers can behave unexpectedly with windowed lists (you need aria-rowcount and aria-rowindex), and SSR hydration needs thought.
When the dataset is huge on the client or work is CPU-heavy, I briefly mention moving work off the main thread and using fast local I/O so the UI thread stays responsive—then I only go deep if they bite:
- Web Workers in frontend and React: parsing, transforms, isolation from the DOM
- Origin Private File System (OPFS): large blobs, streaming, pairing with workers
That keeps the answer honest for dashboards and editors without derailing a 45-minute loop into File System Access API details.
Deep dive: Large Lists: Pagination, Infinite Scroll, and Virtualization
2. Re-rendering issues
The situation: The app is slow during interaction — not on load. Something is causing too many components to re-render too often.
How I explain the debugging process:
I always start with the profiler, not the code. React DevTools Profiler shows you exactly which components re-rendered, how long each took, and, critically, why each re-rendered (prop change, state change, context change, parent re-render). Until you’ve looked at the profiler output, you’re guessing.
The re-rendering mental model I use:
A component re-renders when:
1. Its own state changes
2. Its props change (by reference, not value — objects and functions are recreated every render)
3. A context it subscribes to changes
4. Its parent re-renders (unless wrapped in React.memo with stable props)
The most common production issue I find is context broadcasting — a context that changes frequently (live data, user interaction state) is consumed by a large subtree, causing everything to re-render on every update. The fix is splitting contexts by update frequency: separate the fast-changing data context from the slow-changing config context.
On React.memo, useMemo, and useCallback:
These are referential stability tools, not magic performance switches. React.memo wraps a component in a shallow prop comparison — if all props are the same reference as last render, the re-render is skipped. useCallback gives you a stable function reference across renders. useMemo memoizes a computed value.
The trap I see constantly: developers add useMemo and useCallback everywhere preemptively, adding overhead without benefit. You pay the cost of memoization (comparison + memory) on every render regardless of whether the memoization ever saves a re-render. Profile first. Memoize second.
If the conversation turns to keeping inputs responsive while a subtree is expensive, I add one layer: React 18’s concurrent features defer non-urgent updates so the main thread can still process typing and clicks. That is a different tool from memoization: scheduling**, not skipping work entirely.
Deep dives:
- React Re-rendering: When and Why Component Trees Update
- React.memo, useMemo, useCallback — When They Help vs Hurt
- Concurrent React for perceived performance:
startTransition,useDeferredValue, urgent vs deferred updates
3. Network optimization
The situation: The app makes too many requests, or makes them at the wrong time, or fetches data that’s already available from a previous request.
How I explain the SPA network problem:
Single-page apps have a structural disadvantage vs server-rendered pages: data fetching happens in the browser, sequentially. You navigate to a route → component mounts → useEffect fires → fetch starts → data arrives → render. That’s a full round-trip after every navigation. Stack nested routes that each fetch their own data and you get a waterfall — each fetch waits for the parent component’s render before it even starts.
The pattern I always mention: move fetches out of components and into route loaders (React Router 6.4+ loaders, Next.js getServerSideProps/RSC). A route loader can start the fetch immediately when the user navigates — before the component tree even renders — and pass the data down as a resolved value. No useEffect, no loading spinner, no waterfall.
For data that doesn’t change on every page load, stale-while-revalidate is the principle I use most. TanStack Query implements it well: return cached data immediately, revalidate in the background, update if something changed. Users see instant data; freshness is maintained. The key config knob is staleTime: how long before a cache entry is considered stale.
| Caching strategy | Latency | Freshness | Right for |
|---|---|---|---|
| No cache (fetch on mount) | Network RTT on every load | Always fresh | Auth state, financial data |
| Stale-while-revalidate | Instant (cached) + background update | Slightly stale | Most UI data — profiles, feeds, configs |
| Cache-first with TTL | Instant until TTL expires | Stale until TTL | Mostly-static data — categories, settings |
| Immutable (by URL hash) | Instant indefinitely | Never updates | Build artifacts, versioned API responses |
Other points I always bring up: request deduplication (two components requesting the same URL in the same render cycle should hit the network once, not twice; TanStack Query does this automatically), prefetching on hover before the user clicks, and fetchpriority="high" on LCP-critical resources.
Deep dive: Network Optimization for React SPAs
4. Mobile view optimization
The situation: The app works on a MacBook and a high-end phone. It’s unusable on a mid-range Android. You’ve never actually tested it on one.
How I explain the mobile performance gap:
Desktop performance profiling is misleading. A MacBook Pro has 8–16 fast cores. A Redmi Note 9, which represents a huge portion of actual smartphone users globally, has 4 slower cores, 4GB RAM, and a V8 JavaScript engine running at maybe 20% the throughput of a desktop Chrome instance. The 6x CPU throttle preset in Chrome DevTools is a useful approximation but still optimistic for low-end devices.
The areas I focus on for mobile:
DOM size. Every DOM node has a cost: memory, style matching, hit testing. A style recalculation on a page with 500 nodes is fast. On a page with 5,000 nodes it’s slow. On mobile it’s noticeably slow. I audit DOM size with document.querySelectorAll('*').length and target under 1,500 nodes for complex pages.
content-visibility: auto is the highest-impact single CSS property I reach for on content-heavy mobile pages. It tells the browser to skip layout and paint for off-screen sections entirely; they’re calculated only when they scroll into view. Combined with contain-intrinsic-size to give the browser a height estimate, it can cut initial render time dramatically on long pages.
.article-section {
content-visibility: auto;
contain-intrinsic-size: 0 400px; /* approximate height, prevents layout shift */
}
Passive event listeners. Touch and wheel event listeners delay scroll by default because the browser waits to see if preventDefault() is called. Adding { passive: true } tells the browser it can start scrolling immediately. This is one of the cheapest scroll performance wins available:
document.addEventListener('touchstart', handler, { passive: true });
Images. loading="lazy" for below-fold images, decoding="async", correct width and height attributes to prevent layout shift, and properly sized srcset so mobile devices don’t download desktop-resolution images.
Deep dive: Mobile Web and DOM Performance
5. Build optimizations
The situation: The initial JavaScript payload is too large. Time to Interactive is slow because the browser is parsing and executing megabytes of JS before the app is usable.
How I explain the bundle problem:
Most apps I’ve worked on that had large bundles had the same root causes: no code splitting, importing entire libraries when they only needed one function, and barrel files that prevented tree shaking from working.
I always mention three things:
Bundle analysis first. Before optimizing anything, visualize what’s actually in the bundle. rollup-plugin-visualizer (for Vite) and webpack-bundle-analyzer produce a treemap showing exactly how much each dependency contributes. Nine times out of ten, there’s one or two giant libraries that shouldn’t be there in their entirety: moment.js with all locale files, an unshaken icon library, a full lodash import.
Tree shaking requires ES modules. Tree shaking is static dead code elimination — the bundler can only remove exports that are provably unused. This only works with import/export syntax. CommonJS require() is dynamic, so bundlers can’t statically analyze it. Libraries that ship CommonJS only are not tree-shakeable. Also critical: barrel files (index.js that re-exports everything) break tree shaking even for ES module libraries. The bundler sees an import chain to all exports and can’t determine what’s unused.
Route-level code splitting. React.lazy() + Suspense lets you split your bundle at route boundaries so users only download the code for the page they’re on:
const Dashboard = React.lazy(() => import('./pages/Dashboard'));
const Settings = React.lazy(() => import('./pages/Settings'));
<Suspense fallback={<Spinner />}>
<Routes>
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/settings" element={<Settings />} />
</Routes>
</Suspense>
The impact is significant: a 2MB bundle becomes multiple 200-400KB chunks, and users only pay for the chunks they actually navigate to.
I also mention vendor splitting — separating stable third-party dependencies (React, router, component library) from frequently-changing app code. Stable vendor chunks get long-lived cache headers and don’t invalidate when you ship a feature.
Deep dive: Build Bundles, Tree-shaking, and Code Splitting
6. Asset optimization
The situation: Large images are tanking LCP. Web fonts are causing layout shift. Third-party scripts are blocking the main thread.
How I explain each asset type:
Images and LCP. The largest contentful paint element is almost always an image. The most impactful single change for LCP is adding fetchpriority="high" to the hero image and a <link rel="preload"> in the <head>. The browser’s preload scanner discovers the image earlier and prioritizes its download. Additionally: use WebP or AVIF (30-50% smaller than JPEG at equivalent quality), always set width and height to prevent CLS, and use loading="eager" on above-fold images (don’t lazy-load your LCP element; I’ve seen this mistake in production).
Fonts and CLS. Web fonts cause two problems: invisible text during load (FOIT) and layout shift when the font swaps in. font-display: swap makes text immediately visible using the system fallback font, then swaps to the web font when it loads. This eliminates FOIT but can cause a brief layout shift. font-display: optional skips the swap entirely if the font isn’t cached. There is no layout shift, but users may never see the custom font on a first visit. Which you choose depends on whether the brand cares more about CLS or visual consistency.
The highest-ROI font optimization most teams skip: subsetting. A full Latin font file might be 400KB. If your content only uses basic Latin characters, a subset can be 30-50KB. pyftsubset from the fonttools package does this at build time.
Third-party scripts. Google Tag Manager, chat widgets, analytics: these are often the biggest main-thread bottleneck on marketing sites. A GTM container that loads 12 tags synchronously in the <head> will block rendering for hundreds of milliseconds. The fix: load all third-party scripts with async or defer, move analytics to server-side where possible, and use the facade pattern for heavy embeds (YouTube, Intercom, maps): show a static thumbnail until the user interacts, then load the real widget on click.
Deep dive: Images, Fonts, and Third-Party Performance
7. Long session memory optimization
The situation: The app works fine on first load. After an hour of use, it’s sluggish. After three hours, the tab crashes. Lighthouse still scores 100.
Why I always end with this:
Memory is the area most candidates don’t mention at all, which makes bringing it up a reliable signal of production experience. Lighthouse doesn’t catch memory problems — it runs a short synthetic test on a clean page. A tab that leaks 20MB per minute will crash in two hours but score perfectly in the lab.
The leak patterns I’ve hit in production React apps:
The most common one: a WebSocket or SSE subscription set up in a useEffect that doesn’t clean up on unmount. The subscription holds a reference to the component’s state setter, which holds a reference to the component’s closure, which may hold references to large data structures. The component unmounts and navigation happens, but the subscription keeps firing and accumulates references that the GC can’t collect.
useEffect(() => {
const ws = new WebSocket(url);
ws.onmessage = (e) => setState(JSON.parse(e.data));
return () => ws.close(); // cleanup is critical — without this, you leak
}, [url]);
Other patterns: setInterval without clearInterval in cleanup, global Map or Set caches that grow unboundedly (store 100 items max, then evict the oldest), and third-party library subscriptions that need explicit unsubscribe() calls.
For long-running internal tools (dashboards that ops teams leave open all day), I also recommend:
- Listening to the Page Visibility API and pausing heavy polling when the tab is hidden
- Clearing inactive route caches on a timer (TanStack Query’s
gcTimedoes this automatically) - As a last resort: reload the tab after a configurable idle period (aggressive but effective for kiosk-style deployments)
Deep dives:
- React Memory Leaks: Subscriptions, Closures, and Retained Graphs
- JavaScript Garbage Collection for Frontend Developers
- Lighthouse 100 Still Crashes: Memory, OOM, and Long Sessions
How I put it all together in the interview room
When the question is asked, I don’t just list these seven areas — I sequence my answer to show how I actually work through a performance problem:
- Clarify the scenario (15–20 seconds). “Is this first load, repeat navigation, or long-lived session?” “Mobile, desktop, or both?” “Do we have RUM or only lab?” That signals senior judgment—you are not solving a different company’s app than the one they described.
- Identify the symptom first. Slow load? Runtime jank? Crashes after extended use? Each points to a different area from the table above.
- Measure before touching code. Chrome DevTools Performance panel for runtime, Network panel for requests, Memory panel for heap growth. I always say “I never optimize what I haven’t measured.”
- Prioritize by user impact. A 500ms LCP improvement on the landing page affects every new visitor. A memory leak fix affects users who stay for hours. Both matter but they have different urgency.
- Go area by area, don’t thrash. Fixing re-renders while ignoring a 2MB bundle doesn’t move the needle. Fixing network while the DOM has 10,000 nodes is partial progress. The seven areas interact — understand which one is the real bottleneck for your specific app.
Timeboxing: For a short behavioral question (“how do you approach performance?”), I hit the seven buckets + measurement in under two minutes and offer one concrete example from a past project. For a deep system design or debugging exercise, I walk one path end-to-end (e.g. “I’d open Performance, find long tasks, then inspect commit times in React Profiler…”) and name which bucket I ruled out and why.
STAR without sounding rehearsed: I keep Situation → bottleneck signal → what I measured → fix → outcome in my head. I do not say “STAR” out loud; I just tell the story in that order so the interviewer hears cause, evidence, and trade-offs.
The interviewer isn’t looking for someone who knows every API. They’re looking for someone who can look at a slow production app and work through it systematically without panicking. This framework is how I do that.
For the full index and deep dives, see the series hub.