Fixing a React performance bug without knowing React performance
TL;DR: I had a performance task, no idea what I was doing, and AI kept writing fixes that looked right but weren't. The thing that actually worked was finding real data and letting AI figure out the pattern — hard numbers over vibes.
I got a task of fixing a performance issue, but I really wasn't sure what to do. I know the basics like memoizing components, using callbacks, reducing unnecessary loops, but nothing beyond that. I was stumped, but I did have AI at my side.
Even though I know that there's a React Profiler component and the profiler dev tool, I've never really used them in any meaningful way. I had the idea of letting Cursor loose on that problem, but I knew it needed guardrails — AI is great at writing code that looks like a fix, and because I don't know much about performance optimization I wouldn't be able to judge if it was actually doing anything.
The rules I set for myself:
- AI is really good and eager to write code
- This code might not actually solve any problems: it will look legit, and because I don't know anything about performance optimization I won't be able to judge if a fix is actually meaningful or not
- I need to find a way to measure hard numbers and see them improve with my changes
- I'm really bad at sifting through tons of numbers and data, but AI is great for that
The above points were enough to get me started. First I asked Cursor to add the Profiler component to the chat. Next I also asked it to add logs with timing in useEffects and functions, to see if something was obviously broken — an infinite loop, unnecessary calculations, excess rerenders.
import { Profiler } from 'react'
const onRender = (id, phase, actualDuration) => {
if (actualDuration > 5) {
console.log(`[PERF] ${id} ${phase} ${actualDuration.toFixed(1)}ms`)
}
}
// In the component:
useEffect(() => {
console.log('[PERF] effect:ChatMessage:citations')
}, [citations])
return (
<Profiler id='ChatMessage' onRender={onRender}>
{/* ... */}
</Profiler>
)This was already useful as a first pass and I got actual numbers like these. This is a clean version of what was in the console, in reality there were dozens and hundreds of logs and for a human to understand what's going on is tricky.
[PERF] ChatMessage mount 3.2ms
[PERF] effect:ChatMessage:citations
[PERF] ChatMessage update 1.1ms
[PERF] effect:ChatMessage:citations
[PERF] ChatMessage update 0.9msNow I could confidently rule out anything dramatically broken in that area. But there was no smoking gun. I still wasn't closer to solving the problem.
I'll be quite honest — I've probably opened the React Profiler in the dev tools around 5 times in the last decade: there's a lot happening, it's overwhelming for me. But AI shouldn't be bothered and overwhelmed by a data dump, so why not use it?
I went to the dev tools, recorded a profiling session and reproduced the bug that users reported. Fortunately the profiler lets you export the JSON of the data. With a file full of raw numbers in hand I went to Cursor and dumped it with the words: "here's some data, figure out a pattern". I didn't expect much, but it actually helped.
What the data showed
The profiler data showed that ChatMessage — a component that renders each message in the chat — was rendering 3304 times during a single streaming response. Every one of the 15 finished messages in the conversation was re-rendering on every token coming in, even though their content hadn't changed at all.
The root cause was a prop called allMessages: the full array of chat history passed down to every ChatMessage. Since the array reference changed with each new token, all 15 components failed their memo check and re-rendered. The fix was one condition: only check allMessages for the message that's currently streaming. Finished messages should only respond to changes in their own content.
I shared another profiler export after the fix to confirm it worked. Finished messages went from re-rendering 3304 times to 0 during a stream.
So what made this work?
Looking back, the rules I set before starting held up. The key one was rule 1: measure hard numbers. Every time AI started guessing and hedging — and it did, more than once — the answer was always to get more concrete data. The console logs helped eliminate some dead ends. The profiler JSON cracked it open.
I didn't need to understand what the numbers meant. I just needed to get them in front of something that could. That's the whole trick: use AI to read the data, use data to keep the AI honest.