React Performance Debug and Code Review: 6 Common Performance Bugs and How to Fix Them
In front-end technical screenings and code-review rounds, debugging exercises evaluate whether engineers can diagnose runtime bottlenecks, isolate root causes in React reconciliation, and deliver idiomatic fixes.
Performance problems in React typically stem from improper state distribution, unstable references breaking memoization, cascading effect loops, or unvirtualized DOM trees.
This guide analyzes 6 realistic React performance debugging interview problems for developers with 0–5 years of experience.
Question Index
| # | Buggy Scenario | Focus Area | Difficulty |
|---|---|---|---|
| 1 | Context Thrashing with Monolithic Value Objects | Context re-render propagation, state vs dispatch splitting | Mid |
| 2 | Broken Memoization via Inline Object & Callback Props | React.memo shallow equality, reference stability | Junior-Mid |
| 3 | Redundant Re-renders from Syncing Derived State in useEffect | Cascading render passes, synchronous derivation | Mid |
| 4 | Unmemoized Custom Hook Returning Fresh Object References | Hook return stability, consumer effect invalidation | Mid-Senior |
| 5 | Scroll Jank & Main-Thread Thrashing from State Updates | Event loop, requestAnimationFrame, paint cycles | Mid-Senior |
| 6 | Interaction Latency & Long Tasks from Unvirtualized Lists | DOM node overhead, list windowing, INP degradation | Mid |
Q1. Context Thrashing with Monolithic Value Objects
Level: Mid
Focus Area: Context propagation mechanics, consumer re-render boundaries, and separating high-frequency state from low-frequency state.
Buggy Code Snippet
import React, { createContext, useContext, useState } from 'react';
interface AppContextType {
searchTerm: string;
setSearchTerm: (term: string) => void;
theme: 'light' | 'dark';
setTheme: (theme: 'light' | 'dark') => void;
user: { id: string; name: string };
}
const AppContext = createContext<AppContextType | null>(null);
export function AppProvider({ children }: { children: React.ReactNode }) {
const [searchTerm, setSearchTerm] = useState('');
const [theme, setTheme] = useState<'light' | 'dark'>('light');
const [user] = useState({ id: '101', name: 'Alex Developer' });
// Bug: New object reference created on every keystroke in searchTerm
return (
<AppContext.Provider value={{ searchTerm, setSearchTerm, theme, setTheme, user }}>
{children}
</AppContext.Provider>
);
}
export function NavigationBar() {
// NavigationBar only cares about theme, but re-renders on every search keystroke
const context = useContext(AppContext);
if (!context) return null;
return (
<nav className={`navbar ${context.theme}`}>
<span>Welcome, {context.user.name}</span>
<button onClick={() => context.setTheme(context.theme === 'light' ? 'dark' : 'light')}>
Toggle Theme
</button>
</nav>
);
}
Symptom
Typing into the search input updates searchTerm and forces every component under AppProvider to re-render on every keystroke. Heavy components like NavigationBar re-render constantly, dropping frame rates and causing typing lag.
Root Cause
React Context notifies all consumers whenever its value reference changes (Object.is). In AppProvider, passing an inline object {{ searchTerm, ... }} generates a new reference on every keystroke. Consumers like NavigationBar that only read theme cannot bail out because React Context has no built-in property selectors, and context updates bypass React.memo.
The Fix
Split monolithic contexts into separate, dedicated contexts based on update frequency, and memoize the provider values using useMemo. Separate high-frequency state (search inputs) from stable or low-frequency state (theme, user session).
import React, { createContext, useContext, useState, useMemo } from 'react';
// Separate Contexts by domain and update velocity
const ThemeContext = createContext<'light' | 'dark'>('light');
const ThemeUpdateContext = createContext<((t: 'light' | 'dark') => void) | null>(null);
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setTheme] = useState<'light' | 'dark'>('light');
return (
<ThemeContext.Provider value={theme}>
<ThemeUpdateContext.Provider value={setTheme}>
{children}
</ThemeUpdateContext.Provider>
</ThemeContext.Provider>
);
}
export function NavigationBar() {
const theme = useContext(ThemeContext);
const setTheme = useContext(ThemeUpdateContext);
return (
<nav className={`navbar ${theme}`}>
<button onClick={() => setTheme?.(theme === 'light' ? 'dark' : 'light')}>
Toggle Theme
</button>
</nav>
);
}
Spoken Explanation to the Interviewer
"The root cause is context thrashing caused by bundling high-frequency state like searchTerm with low-frequency state in a single provider using an inline object literal. Whenever searchTerm changes, a new object reference is passed to value, forcing all consumers across the tree to re-render—even if they only read theme. The fix is splitting the monolithic context into focused providers: one for theme state, one for theme updates, and keeping search state scoped locally."
Q2. Broken Memoization via Inline Object & Callback Props
Level: Junior-Mid
Focus Area: React.memo shallow comparison, reference identity, and stabilizing callback references with useCallback.
Buggy Code Snippet
import React, { useState } from 'react';
interface Item {
id: string;
name: string;
price: number;
}
// Child component wrapped in React.memo to prevent expensive re-renders
const ProductCard = React.memo(function ProductCard({
item,
config,
onSelect,
}: {
item: Item;
config: { currency: string; showBadge: boolean };
onSelect: (id: string) => void;
}) {
console.log(`Rendering ProductCard: ${item.name}`);
return (
<div className="card" onClick={() => onSelect(item.id)}>
<h4>{item.name}</h4>
<p>{config.currency}{item.price}</p>
</div>
);
});
export function ProductCatalog({ items }: { items: Item[] }) {
const [filter, setFilter] = useState('');
return (
<div>
<input value={filter} onChange={(e) => setFilter(e.target.value)} placeholder="Filter" />
<div className="grid">
{items.map((item) => (
<ProductCard
key={item.id}
item={item}
// Bug 1: New object literal reference on every render
config={{ currency: '$', showBadge: true }}
// Bug 2: Inline arrow function creates new function reference
onSelect={(id) => console.log('Selected:', id)}
/>
))}
</div>
</div>
);
}
Symptom
Typing in the search input triggers re-renders for every card in the catalog. Even though ProductCard is wrapped in React.memo and its item prop is unchanged, every card re-renders on every keystroke, causing sluggish frame rates.
Root Cause
React.memo uses shallow equality (prevProps === nextProps) to decide whether to re-render:
config={{ currency: '$', showBadge: true }}: Creates a fresh object reference on every parent render.onSelect={(id) => ...}: Creates a new arrow function reference on every render. Because both references differ on each render,React.memoshallow comparison fails, forcing full child re-renders.
The Fix
Stabilize object and function references. Move static objects outside the component scope (or wrap dynamic objects in useMemo), and stabilize callback handlers using useCallback. Alternatively, pass primitive values instead of complex objects.
import React, { useState, useCallback } from 'react';
// 1. Move static configurations outside component scope to maintain stable reference
const DEFAULT_CONFIG = { currency: '$', showBadge: true };
export function ProductCatalog({ items }: { items: Item[] }) {
const [filter, setFilter] = useState('');
// 2. Stabilize callback reference across renders
const handleSelect = useCallback((id: string) => {
console.log('Selected item:', id);
}, []);
return (
<div>
<input value={filter} onChange={(e) => setFilter(e.target.value)} />
<div className="grid">
{items.map((item) => (
<ProductCard
key={item.id}
item={item}
config={DEFAULT_CONFIG}
onSelect={handleSelect}
/>
))}
</div>
</div>
);
}
Spoken Explanation to the Interviewer
"React.memo uses shallow equality to compare props. Passing an inline object literal and inline arrow function creates new memory references on every render cycle. React.memo sees different references and re-renders the child. We fix this by hoisting static configuration outside the component and wrapping the event handler in useCallback with empty dependencies."
Q3. Redundant Re-renders from Syncing Derived State in useEffect
Level: Mid
Focus Area: Avoiding derived state anti-patterns, eliminating cascading render loops, and synchronous render calculations.
Buggy Code Snippet
import React, { useState, useEffect } from 'react';
interface Transaction {
id: string;
amount: number;
category: string;
}
export function TransactionDashboard({ transactions }: { transactions: Transaction[] }) {
const [selectedCategory, setSelectedCategory] = useState('all');
// Anti-pattern: Storing derived data in state
const [filteredList, setFilteredList] = useState<Transaction[]>([]);
const [totalSum, setTotalSum] = useState<number>(0);
// Bug: Effect triggers a secondary render cycle after prop/state update
useEffect(() => {
const list = selectedCategory === 'all'
? transactions
: transactions.filter((t) => t.category === selectedCategory);
const sum = list.reduce((acc, t) => acc + t.amount, 0);
setFilteredList(list);
setTotalSum(sum);
}, [transactions, selectedCategory]);
return (
<div>
<select value={selectedCategory} onChange={(e) => setSelectedCategory(e.target.value)}>
<option value="all">All</option>
<option value="groceries">Groceries</option>
</select>
<h3>Total: ${totalSum}</h3>
<ul>
{filteredList.map((t) => (
<li key={t.id}>{t.category}: ${t.amount}</li>
))}
</ul>
</div>
);
}
Symptom
Switching categories causes two render passes. React renders first with updated selectedCategory but stale filteredList data, then executes useEffect and triggers an immediate second render. This causes UI stuttering and wasted CPU cycles.
Root Cause
This is the "syncing derived state with effects" anti-pattern. Filtering an array and summing numbers can be computed synchronously during render. Using useEffect forces React to commit stale data, execute the effect callback, and schedule an immediate secondary reconciliation pass for every interaction.
The Fix
Eliminate the extra state variables and calculate derived values synchronously during the render phase. Use useMemo if the array calculation involves thousands of elements.
import React, { useState, useMemo } from 'react';
export function TransactionDashboard({ transactions }: { transactions: Transaction[] }) {
const [selectedCategory, setSelectedCategory] = useState('all');
// Compute derived state synchronously during render with useMemo
const filteredList = useMemo(() => {
return selectedCategory === 'all'
? transactions
: transactions.filter((t) => t.category === selectedCategory);
}, [transactions, selectedCategory]);
const totalSum = useMemo(() => {
return filteredList.reduce((acc, t) => acc + t.amount, 0);
}, [filteredList]);
return (
<div>
<select value={selectedCategory} onChange={(e) => setSelectedCategory(e.target.value)}>
<option value="all">All</option>
<option value="groceries">Groceries</option>
</select>
<h3>Total: ${totalSum}</h3>
<ul>
{filteredList.map((t) => (
<li key={t.id}>{t.category}: ${t.amount}</li>
))}
</ul>
</div>
);
}
Spoken Explanation to the Interviewer
"The issue is redundant rendering caused by syncing derived state in a useEffect. When selectedCategory updates, React renders the component once with stale list data, runs the effect, and then renders a second time. Following the official 'You Might Not Need an Effect' guidelines, derived data should be computed synchronously during render, memoized with useMemo only if computationally expensive."
Q4. Unmemoized Custom Hook Returning Fresh Object References
Level: Mid-Senior
Focus Area: Custom hook reference stability, consumer dependency array invalidation, and preventing infinite effect loops.
Buggy Code Snippet
import React, { useState, useEffect, useCallback } from 'react';
// Custom hook to fetch user details and provide refresh capabilities
function useUserProfile(userId: string) {
const [user, setUser] = useState<{ id: string; name: string } | null>(null);
const fetchUser = async () => {
const res = await fetch(`/api/users/${userId}`);
const data = await res.json();
setUser(data);
};
useEffect(() => {
fetchUser();
}, [userId]);
// Bug: Returns a newly constructed object literal on every render
return {
user,
refresh: fetchUser,
};
}
export function UserStatusWidget({ userId }: { userId: string }) {
const { user, refresh } = useUserProfile(userId);
// Consumer effect depends on hook return method
useEffect(() => {
console.log('Setting up real-time status subscription for user...');
const interval = setInterval(() => {
refresh();
}, 10000);
return () => clearInterval(interval);
}, [refresh]); // Bug: refresh changes identity on every render!
return <div>Active User: {user?.name ?? 'Loading...'}</div>;
}
Symptom
The UserStatusWidget sets up and tears down its polling timer on every single render. If refresh() updates state, an infinite re-render loop or rapid memory thrashing occurs, causing browser console warnings and high CPU utilization.
Root Cause
In useUserProfile, fetchUser is defined inline without useCallback, and the hook returns a fresh object literal { user, refresh: fetchUser } on every render. Because refresh changes identity every cycle, consumer effects depending on refresh tear down and restart on every render, causing timer churn and infinite update loops.
The Fix
Wrap the action functions in useCallback with proper dependencies, and memoize the returned object using useMemo (or return a stable tuple array like useState).
import { useState, useEffect, useCallback, useMemo } from 'react';
function useUserProfile(userId: string) {
const [user, setUser] = useState<{ id: string; name: string } | null>(null);
// 1. Stabilize function reference with useCallback
const fetchUser = useCallback(async () => {
const res = await fetch(`/api/users/${userId}`);
const data = await res.json();
setUser(data);
}, [userId]);
useEffect(() => {
fetchUser();
}, [fetchUser]);
// 2. Memoize returned object reference so consumer dependency arrays stay stable
return useMemo(() => ({
user,
refresh: fetchUser,
}), [user, fetchUser]);
}
Spoken Explanation to the Interviewer
"A custom hook is an API contract. When a hook returns an unmemoized object or unstable functions, consumers that include those return values in useEffect dependency arrays will trigger unnecessary effect re-runs or infinite loops. By stabilizing fetchUser with useCallback and memoizing the return object with useMemo, we guarantee stable identity across renders."
Q5. Scroll Jank & Main-Thread Thrashing from State Updates
Level: Mid-Senior
Focus Area: Browser frame budgets, main thread bottlenecking, decoupling DOM scroll tracking from React state reconciliation.
Buggy Code Snippet
import React, { useState, useEffect } from 'react';
export function ScrollProgressBar() {
const [scrollProgress, setScrollProgress] = useState(0);
useEffect(() => {
const handleScroll = () => {
const totalHeight = document.documentElement.scrollHeight - window.innerHeight;
const progress = totalHeight > 0 ? (window.scrollY / totalHeight) * 100 : 0;
// Bug: Calling setState synchronously on every scroll event (60-120Hz)
setScrollProgress(progress);
};
window.addEventListener('scroll', handleScroll);
return () => window.removeEventListener('scroll', handleScroll);
}, []);
return (
<div
style={{
width: `${scrollProgress}%`,
height: '4px',
backgroundColor: '#3b82f6',
position: 'fixed',
top: 0,
left: 0,
zIndex: 1000,
}}
/>
);
}
Symptom
While scrolling, the page stutters noticeably, dropping frames below 30fps. The browser DevTools Performance panel reveals a continuous wall of red "Long Task" warnings and forced layout recalculations thrashing the main thread.
Root Cause
Browsers fire scroll events at display refresh rates (60Hz–120Hz). Calling setScrollProgress on every event forces React reconciliation and inline style recalculations on every frame. Reading DOM scroll offsets in lockstep induces layout thrashing. React state is designed for discrete data changes, not high-frequency animations.
The Fix
Bypass React state reconciliation for continuous animations. Use direct DOM manipulation with requestAnimationFrame to batch writes, or prefer modern CSS scroll-driven animations (animation-timeline: scroll()) where supported.
import React, { useEffect, useRef } from 'react';
export function ScrollProgressBar() {
const barRef = useRef<HTMLDivElement>(null);
useEffect(() => {
let ticking = false;
const updateBar = () => {
const total = document.documentElement.scrollHeight - window.innerHeight;
const progress = total > 0 ? (window.scrollY / total) * 100 : 0;
if (barRef.current) {
// Direct CSS transform: GPU-accelerated, zero React reconciliation overhead
barRef.current.style.transform = `scaleX(${progress / 100})`;
}
ticking = false;
};
const onScroll = () => {
if (!ticking) {
window.requestAnimationFrame(updateBar);
ticking = true;
}
};
window.addEventListener('scroll', onScroll, { passive: true });
return () => window.removeEventListener('scroll', onScroll);
}, []);
return (
<div
ref={barRef}
style={{
width: '100%',
height: '4px',
backgroundColor: '#3b82f6',
position: 'fixed',
top: 0,
left: 0,
zIndex: 1000,
transformOrigin: 'left',
transform: 'scaleX(0)',
willChange: 'transform',
}}
/>
);
}
Spoken Explanation to the Interviewer
"Triggering setState at 120Hz inside a raw scroll listener overloads React reconciliation and forces synchronous layout reflows. The solution is decoupling scroll animation from React state: we attach a passive listener, batch updates using requestAnimationFrame, and mutate a GPU-accelerated CSS transform directly on the DOM ref."
Q6. Interaction Latency & Long Tasks from Unvirtualized Lists
Level: Mid
Focus Area: DOM node bloat, windowing/virtualization mechanics, Interaction to Next Paint (INP) optimization.
Buggy Code Snippet
import React, { useState } from 'react';
interface FeedItem {
id: string;
author: string;
content: string;
timestamp: string;
}
export function ActivityFeed({ initialItems }: { initialItems: FeedItem[] }) {
const [items] = useState<FeedItem[]>(initialItems); // Array of 3,000 items
const [selectedId, setSelectedId] = useState<string | null>(null);
return (
<div className="feed-container" style={{ height: '600px', overflowY: 'auto' }}>
{/* Bug: Instantiating and rendering 3,000 full DOM nodes simultaneously */}
{items.map((item) => (
<div
key={item.id}
className={`feed-card ${selectedId === item.id ? 'active' : ''}`}
onClick={() => setSelectedId(item.id)}
>
<h4>{item.author}</h4>
<p>{item.content}</p>
<time>{item.timestamp}</time>
</div>
))}
</div>
);
}
Symptom
Initial page load is sluggish, and clicking any item to set selectedId freezes the UI for 300–600ms. Chrome DevTools highlights severe Interaction to Next Paint (INP) violations with long tasks lasting over 400ms.
Root Cause
The component mounts 3,000 DOM nodes simultaneously despite showing only ~6 in the viewport. The browser maintains layout trees and style calculations for all 3,000 nodes. When selectedId changes, React re-diffs 3,000 virtual DOM nodes and forces massive layout passes, blocking the main thread and degrading INP.
The Fix
Implement list virtualization (windowing). Only render the items that fit inside the visible scroll viewport plus a small buffer (overscan). Use container scroll offset and fixed item heights to calculate starting and ending indices.
import React, { useState } from 'react';
const ITEM_HEIGHT = 80;
const VIEWPORT_HEIGHT = 600;
const OVERSCAN = 3;
export function VirtualizedFeed({ items }: { items: FeedItem[] }) {
const [scrollTop, setScrollTop] = useState(0);
const [selectedId, setSelectedId] = useState<string | null>(null);
const totalHeight = items.length * ITEM_HEIGHT;
const startIndex = Math.max(0, Math.floor(scrollTop / ITEM_HEIGHT) - OVERSCAN);
const endIndex = Math.min(
items.length,
Math.ceil((scrollTop + VIEWPORT_HEIGHT) / ITEM_HEIGHT) + OVERSCAN
);
const visibleItems = items.slice(startIndex, endIndex);
return (
<div
style={{ height: VIEWPORT_HEIGHT, overflowY: 'auto', position: 'relative' }}
onScroll={(e) => setScrollTop(e.currentTarget.scrollTop)}
>
<div style={{ height: totalHeight, position: 'relative' }}>
{visibleItems.map((item, index) => {
const itemIndex = startIndex + index;
return (
<div
key={item.id}
onClick={() => setSelectedId(item.id)}
style={{
position: 'absolute',
top: itemIndex * ITEM_HEIGHT,
left: 0,
right: 0,
height: ITEM_HEIGHT,
}}
>
<h4>{item.author}</h4>
<p>{item.content}</p>
</div>
);
})}
</div>
</div>
);
}
Spoken Explanation to the Interviewer
"Rendering thousands of DOM elements degrades memory and causes long tasks during interaction reconciliation. The solution is windowing: we calculate visible item indices based on scrollTop and fixed item height, rendering only the ~12 visible items in the viewport while using a spacer div to preserve the native scrollbar."
Preparation Checklist
Before your next front-end performance interview, verify you can comfortably address these key topics:
- Context Splitting: Separate high-frequency state from low-frequency state to avoid invalidating tree memoization.
- Reference Identity: Explain why inline objects and arrow functions break
React.memoshallow comparison. - Derived State Calculations: Compute filtered/sorted values synchronously during render instead of cascading effects.
- Custom Hook Identity: Guarantee stable function and object references returned from reusable custom hooks.
- Animation Off-loading: Decouple rapid event handlers (scroll/resize) from React state using
requestAnimationFrame. - List Virtualization: Implement windowing algorithms to keep DOM node counts bounded regardless of data volume.
Conclusion
React performance optimization is not about arbitrarily wrapping every component in React.memo or every function in useCallback. True mastery involves identifying root bottlenecks—such as context thrashing, unstable reference boundaries, cascading effect updates, and DOM bloat—and applying targeted architectural fixes that preserve clean code and fast user experiences.