Memoization that is theatre
A memo boundary only pays when the props stop changing — under a stream whose props change on every chunk, wrapping the message list in memo adds a comparison to every render and removes none of them.
Your trace shows the picket fence: one near-identical stack per chunk, each topped by the same commit work, and a stop click that arrived in the middle of it. You take that to a colleague and the first suggestion arrives inside ten seconds. Wrap the transcript in memo.
It is the reflex fix for the phrase “too many re-renders,” and on a stream it is usually theatre. Not because memoization is a bad idea, and not because React’s implementation is weak, but because memo has one precondition and a token stream violates it on purpose, every chunk, by design.
What memo actually promises
React’s memo reference states the contract with the condition attached:
This memoized version of your component will usually not be re-rendered when its parent component is re-rendered as long as its props have not changed.
Two hedges in one sentence, and both are load-bearing. Usually is there because the same page says plainly that “memoization is a performance optimization, not a guarantee” — React reserves the right to re-render a memoized component anyway. As long as its props have not changed is the precondition, and “changed” has an exact definition: React compares each prop with Object.is. Shallow, per prop, reference equality for objects and arrays.
The same reference then says the quiet part directly:
memois completely useless if the props passed to your component are always different, such as if you pass an object or a plain function defined during rendering.
Read that against your streaming code. On every chunk you build a new accumulated string, push it into a new messages array, and hand that array down. Every prop is structurally new. Every comparison fails.
What the boundary costs when it never hits
A memo boundary is not free when it misses. React still has to run the comparison — walk the props, call Object.is on each one — and then re-render anyway. You have added work to every render and removed none of them.
Do not put a millisecond figure on that comparison. It depends on how many props you pass and how many boundaries you have, and no source publishes a number that would transfer to your app. What you can say is the direction, and then measure the magnitude yourself, which is the hands-on task at the bottom of this lesson.
// The boundary that cannot hit
const Transcript = memo(function Transcript({ messages, onStop }) {
/* ... */
})
function Chat() {
const [text, setText] = useState('')
// new array identity on every chunk -> Object.is fails
const messages = history.concat({ role: 'assistant', text })
// new function identity on every render -> Object.is fails again
return <Transcript messages={messages} onStop={() => abort()} />
}Note that the second failure is independent of the first. Even if you stabilised messages, the inline arrow function passed as onStop is a new value on every render, and one failed comparison is enough. This is why memo retrofitted onto an existing component so rarely does anything: it needs every prop to cooperate, and the props were not written with that in mind.
Where people get burned
The failure mode that costs the most time is not the boundary that does nothing. It is the boundary that does nothing while everyone believes it works. memo leaves no runtime evidence when it misses — no warning, no log, nothing in the diff. A codebase can accumulate a dozen of them, each added after a slow afternoon, none of them ever hitting, and the only way to find out is to measure.
Where memo still pays, and this is not a small exception
“Memo is useless” is the overcorrection, and it is wrong. The precondition is about props that are structurally new every render, not about memoization as an idea. In a transcript, most messages are finished. They will never change again. If each completed message is passed as a stable object reference — the same object identity across renders, not a fresh one rebuilt from the accumulator each time — then a per-message memo boundary skips real work on every chunk, and the amount it skips grows with the length of the conversation.
Which points at the actual sequence. The reference’s own advice is to minimise prop changes first and add memoization second, and that order is the whole lesson:
- Split the boundary so the changing part is small. One
memoaround the whole transcript can never hit while the last message is growing. One around each message hits for every message except the last. - Make the stable props actually stable. Keep completed message objects by identity rather than reconstructing the array from scratch. Hoist handlers out of the render body.
- Then measure whether the boundary earns its keep. Not before.
Even done perfectly, this is a mitigation and not the fix. It reduces the per-render cost of a render that still happens on every chunk. The keeping-the-stream-out-of-state lesson attacks the count instead of the cost, which is a different and larger lever.
How to tell, instead of arguing
React ships the instrument that settles this. The <Profiler> reference gives onRender two durations: actualDuration, the time spent rendering this tree for the current update, and baseDuration, an estimate of what it would cost with no optimisations at all. React’s own wording is that actualDuration “indicates how well the subtree makes use of memoization.”
So the test is a ratio, not an opinion. If actualDuration tracks baseDuration update after update, no memoization in that subtree is doing anything, regardless of how many memo calls appear in the source. If actualDuration falls well below baseDuration once the transcript is long, the per-message boundaries are hitting.
Check your recall
Answer from memory — no scrolling back.
Retrieval check
You split the transcript into per-message memo boundaries and the Profiler ratio does not move at all. What is the most likely cause?
Check your answer
The parent is still rebuilding the message objects. A common shape is mapping over raw accumulator state and constructing { role, text } literals inside the render body, which produces a new object for every message on every chunk — including the ones that finished ten seconds ago. Every boundary then receives a structurally new prop and misses, exactly as before, and you have multiplied the number of failed comparisons rather than the number of skipped renders.
The other candidate is context. A memoized component that reads a context value re-renders when that context changes, regardless of its props. If your transcript components consume a context whose value is rebuilt on every chunk, the memo boundary is transparent to it.
Both are cases of the same underlying rule: memoization can only skip work that nothing else has already invalidated. Look upstream of the boundary, not at the boundary.
Hands on
Prove or kill the memo boundary with a ratio
Done when: MEASUREMENTS.md records actualDuration and baseDuration for the transcript subtree during a stream, with and without the memo boundary, at the same throttling setting — and a one-line verdict saying whether the boundary skipped any work.
- Wrap the transcript subtree in
<Profiler id='transcript'>with anonRenderthat pushesactualDurationandbaseDurationinto an array onwindow. Do not log per render; a stream will drown the console and the logging itself becomes the slow part. - Set the same CPU throttling preset you recorded in the profiling lesson. Write the preset name down again here rather than trusting that it is still selected.
- Run one stream to completion with your current memoization in place. Record the median of both durations across the updates, and the transcript length at the end.
- Remove the
memoboundary. Change nothing else. Run the same prompt again and record both durations again. - Compare. If the two runs are indistinguishable, the boundary was theatre, and you have the evidence to delete it and to say why. Write that verdict into
MEASUREMENTS.mdin one line. - Now split the boundary: memoize each message rather than the transcript, and stop reconstructing completed message objects on every chunk. Re-run and record a third set of numbers. If the ratio still does not move, use the retrieval check above before you change anything else.
- Bring all three sets into the chat. I will push back if the runs used different throttling settings, different prompts, or a transcript short enough that the per-message win could not show up.
What this does not cover
This lesson is about hand-written memoization. The obvious next question — whether React Compiler makes the whole argument obsolete by writing the memoization for you — is the compiler-does-not-fix-this lesson, which is where the precise version of that claim lives. The compiler changes who writes the memo. It does not change the precondition you just watched fail.
Nothing here reduces how often React renders. Splitting boundaries and stabilising props makes each render cheaper; the keeping-the-stream-out-of-state lesson is where the render count itself comes down, and the external-store lesson is how to expose a mutable buffer to the tree without fighting React’s model.
And if your trace named presentation delay rather than input delay as the dominant phase, no amount of memo work is going to move it. That case is a DOM-size and re-parsing problem, and it belongs to the payload module — virtualizing a long conversation, and the cost of parsing every token. The stream itself, its transport and its abort semantics stay with the Streaming interfaces course throughout.
Read this next — primary source
memoreact.dev — free; fetched 5 September 2026, no revision date shown on the page. Meta documenting its own library.
This lesson takes the contract, the comparison rule and the two sentences that decide whether a memo boundary can ever pay. The full reference adds what a single lesson has to compress: how to write a custom areEqual comparison and why React warns against comparing deeply, how memo interacts with state inside the memoized component (state updates still re-render it), how context makes a memo boundary transparent, and a worked example of minimizing prop changes so that memoization becomes possible in the first place. Read the Caveats section twice.
Stuck, curious, or think this lesson is wrong? Ask your teaching agent. The lessons are the scaffold; the conversation is where the learning gets unstuck.