Three weeks. That's how long I spent debugging performance issues in a React application that should have been straightforward. A call queue system for 300 daily users—nothing exotic. But the app was laggy, updates were slow, and users were complaining.
The worst part? Every single issue was my own fault. Not React's. Not the browser's. Mine.
Here are the mistakes I made so you don't have to repeat them.
Mistake #1: Re-rendering the Entire List
The call queue displayed about 50-100 active calls at any time. Each call had real-time status updates—duration, status changes, notes. My initial implementation looked innocent enough:
function CallQueue({ calls }) {
return (
<div className="queue">
{calls.map(call => (
<CallCard key={call.id} call={call} />
))}
</div>
);
}
The problem? Every time any call updated, the entire list re-rendered. With 100 calls updating every few seconds, the browser was doing thousands of unnecessary renders per minute.
The Fix: React.memo and Proper Key Usage
const CallCard = React.memo(({ call }) => {
return (
<div className="call-card">
{/* card content */}
</div>
);
});
But React.memo alone wasn't enough. I was creating new object references on every render:
// Bad: creates new object every render
<CallCard call={{ ...call, formattedDuration: formatDuration(call.duration) }} />
// Good: compute in the child or memoize
<CallCard call={call} />
React.memo only works if props are actually stable. If you're spreading objects or creating new references, you're defeating the purpose.
Mistake #2: useEffect Dependency Hell
I had a useEffect that synced call data with the server. It looked like this:
useEffect(() => {
const interval = setInterval(() => {
fetchCallUpdates(selectedQueue, filters);
}, 5000);
return () => clearInterval(interval);
}, [selectedQueue, filters]);
Seems reasonable, right? The problem was filters was an object created fresh on every render. Every render created a new interval. Memory leaks everywhere.
The Fix: Stabilize Your References
// Option 1: useMemo for objects
const stableFilters = useMemo(() => ({
status: statusFilter,
priority: priorityFilter
}), [statusFilter, priorityFilter]);
// Option 2: Use individual values in dependency array
useEffect(() => {
const interval = setInterval(() => {
fetchCallUpdates(selectedQueue, { status: statusFilter, priority: priorityFilter });
}, 5000);
return () => clearInterval(interval);
}, [selectedQueue, statusFilter, priorityFilter]);
Mistake #3: State Updates in Loops
When bulk-updating calls, I was doing this:
// Terrible: triggers re-render for each call
calls.forEach(call => {
updateCall(call.id, { status: 'closed' });
});
With 50 calls selected, that's 50 state updates, 50 re-renders. The UI froze for several seconds.
The Fix: Batch Updates
// Good: single state update
const updatedCalls = calls.map(call => ({
...call,
status: 'closed'
}));
setCalls(updatedCalls);
React 18's automatic batching helps, but you still need to think about your update patterns. Don't rely on the framework to fix fundamentally inefficient code.
Mistake #4: Inline Functions in JSX
I knew this was "bad practice" but never understood why until it bit me:
// Bad: new function created every render
<CallCard
onStatusChange={(status) => handleStatusChange(call.id, status)}
/>
This breaks memoization because the function reference changes every render. Combined with my list of 100 calls, I was creating 100 new functions every time anything changed.
The Fix: useCallback or Move the Logic
// Option 1: useCallback with stable dependencies
const handleStatusChange = useCallback((callId, status) => {
// handle change
}, []);
// Then pass the call ID from the child
<CallCard
callId={call.id}
onStatusChange={handleStatusChange}
/>
Mistake #5: Not Using the DevTools
I spent two weeks guessing at performance issues before finally installing React DevTools Profiler. Within an hour of profiling, I found all the issues above.
1. Install React DevTools
2. Open Profiler tab
3. Click "Record"
4. Perform the slow action
5. Look for components with high render counts
6. Check "Why did this render?" for each one
The Profiler shows you exactly which components re-rendered and why. It's the difference between guessing and knowing.
The Results
After fixing these issues:
- Initial render: 2.3s → 0.4s
- Update latency: 800ms → 16ms
- Memory usage: Stable (no more leaks)
- User complaints: Zero
The app went from "barely usable" to "snappy" with no architectural changes. Same components, same features—just better implementation.
What I Learned
1. React is fast until you make it slow. Most performance problems are self-inflicted. The framework isn't the bottleneck—your code is.
2. Profile before optimizing. I wasted time optimizing things that weren't problems. The Profiler tells you exactly where to focus.
3. Understand reference equality. Most React performance issues come down to creating new object/function references when you shouldn't.
4. Test with realistic data. My development data had 5 calls. Production had 100. Always test with production-scale data.
5. Read the docs. Every issue I had was documented. I just didn't read carefully enough.
Your Checklist
Before you ship that React app:
- Profile with React DevTools
- Check for unnecessary re-renders in lists
- Verify useEffect dependencies are stable
- Avoid inline functions in frequently-rendered components
- Test with realistic data volumes
Three weeks of my life went to learning these lessons the hard way. Hopefully this post saves you the same pain.
Building a React App?
I help teams optimize their React applications and establish performance best practices. If your app is struggling, let's talk.
Get in Touch