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:

  1. AI is really good and eager to write code
  2. 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
  3. I need to find a way to measure hard numbers and see them improve with my changes
  4. 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.9ms

Now 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.