Virtualizing a long conversation
A conversation is the hostile case for virtualization — variable heights, content that grows while it is on screen, and a scroll position that must stay pinned to the bottom — which is why the naive windowing setup makes the streaming message jump.
Your trace said presentation delay. The callbacks finished, and the frame still could not paint. On a chat surface that verdict has a boring, physical cause more often than a clever one: the transcript is enormous, and the browser is doing style recalculation and layout across every node in it before it can present anything.
The fix for DOM size is windowing — render only the messages in and near the viewport, and give the scroll container the height it would have had. It is a well-worn technique with a well-worn set of libraries. And a conversation is close to the worst list you could apply it to.
Three properties, and each one breaks something
TanStack Virtual now ships a guide dedicated to this case, which is itself the finding. Its announcement post (25 May 2026) states the problem in almost the terms a profile would:
- It grows from the wrong end. “New output appears at the end. Older history loads by prepending items at the start.” A normal list is anchored at the top and grows down. This one is anchored at the bottom and grows at both ends.
- An item changes size after it is rendered. “The last message can grow token by token while the model is streaming.” Not a size you did not know yet — a size that is different one frame later.
- Scroll position is a stated intention. “If someone scrolls up to read history, incoming messages shouldn’t yank them back to the bottom, and if they’re already there, the UI should stay pinned.” Whether to follow depends on what the user did last, not on what arrived.
Hold the vendor position while you read those. TanStack wrote that post to introduce TanStack Virtual’s chat support, so it is a company describing a problem it is about to sell you the answer to. The reason it is still worth citing is the second-order signal: a virtualization library that has existed for years added APIs specifically for this, which is the maintainers stating that the general-purpose setup did not cover it.
Why the naive setup makes the streaming message jump
The mechanism is the second property. A windowing library has to know how tall each item is to place the ones it is not rendering, so it asks you. web.dev’s react-window article gives the two shapes of that question: use FixedSizeList “if you have a long, one-dimensional list of equally sized items,” and VariableSizeList “to render a list of items that have different sizes,” where the list “expects a function for the itemSize prop instead of a specific value.”
A function of index. Asked once, cached, and used to compute the offset of everything after it. That is a perfectly good model for a list whose items differ from each other but not from themselves. A message that gains a paragraph while it is on screen breaks it: the cached height is now wrong, every offset below it is wrong by the same amount, and the library will not find out until something tells it to discard the measurement. The visible symptom is the jump — content settling into a position that does not match where it was drawn a frame ago.
Where people get burned
That article is dated 29 April 2019, which makes it the oldest source this course cites by a wide margin — older than the profiling lesson’s oldest page. It is still the clearest short statement of the fixed-versus-variable vocabulary, which is why it is here. It says nothing about content that resizes at runtime, because in 2019 nothing about a list did that. Do not take an implementation pattern from it.
What a chat-aware virtualizer adds
TanStack Virtual’s chat guide names two options that exist for exactly the two properties above:
const virtualizer = useVirtualizer({
count: messages.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 72,
getItemKey: (index) => messages[index]!.id,
anchorTo: 'end',
followOnAppend: true,
scrollEndThreshold: 80,
overscan: 6
})anchorTo: ‘end’ is described there as being for “prepend stability and streaming bottom growth” — the first and second properties, the list that grows at both ends and the last item that changes size under you. followOnAppend is the third: it will “keep the viewport pinned to the end when a new message arrives and the user was already at the end,” and “if the user has scrolled up to read history, appended messages do not pull them away.”
Note getItemKey in that snippet as well. Identity by index is what makes a prepend look like every message changing at once, which is both a correctness problem and a rendering one.
What virtualization costs you
Removing nodes from the DOM removes them from everything that reads the DOM. Browser find-in-page cannot match text that is not rendered. Anything that walks the document, including assistive technology, sees only the window. Anchor links into an old message stop resolving. Copying the whole transcript by selecting it stops working.
None of that is a reason not to virtualize a transcript that has grown past what the browser can lay out in a frame. It is a reason to know what you traded and to say so out loud, because these are the complaints that arrive weeks later and get reported as unrelated bugs. Measure first: on a transcript short enough that presentation delay is fine, virtualization is a large amount of complexity bought with nothing.
Check your recall
Answer from memory — no scrolling back.
Retrieval check
You add virtualization and INP gets worse rather than better. Name two plausible reasons before you revert.
Check your answer
First: your dominant phase was never presentation delay. If the trace said input delay — the render queue backed up in front of the click — then windowing did not touch the cause and added per-frame measurement work on top of it. That is the split the profiling lesson exists to make you commit to before you pick a fix.
Second: the virtualizer is measuring on every chunk. A dynamically measured list re-reads item sizes as content changes, and forcing that measurement inside the same frame as the update is a synchronous layout read in the hottest loop you have. The instrument that distinguishes these is the same one as before: record a trace, find the long task that overlaps the interaction, and read the top named frame in it.
Either way, revert only after you know which. A fix that made a number worse has told you something specific about your surface, and discarding it without reading it wastes the measurement.
Hands on
Prove DOM size is what you are paying for
Done when: MEASUREMENTS.md carries a before/after pair for the same stop-button interaction at the same CPU throttling setting, each with the transcript node count next to the presentation-delay figure, and a one-line verdict on whether the phase that dominated before still dominates.
- Grow a transcript to the length that actually hurts — keep prompting until the surface feels wrong, then record how many messages that took. Write the number down; it is the threshold this decision rests on.
- Count the nodes in the scroll container before changing anything, from the console:
document.querySelector('#transcript').querySelectorAll('*').length. Write it into the file next to the message count. - Record a trace at your calibrated throttling preset, click stop mid-stream, and read the phase split from the Interactions track. Same procedure as the profiling lesson, same preset. A different preset makes the comparison worthless.
- Add windowing. Use a virtualizer with explicit end-anchoring and conditional follow rather than a generic fixed or variable size list — and give items a stable key from the message id, not the index.
- Re-count the nodes and re-record the trace at the same preset with the same prompt and the same click timing. Write both numbers in.
- Answer the question the file asks: did the phase that dominated before still dominate after? If presentation delay dropped and input delay now leads, you have not failed — you have finished this problem and uncovered the next one, which the React module owns.
- Scroll up mid-stream and confirm you are not dragged back down, then scroll to the bottom and confirm you are followed. Then try find-in-page for a phrase in an old message and note what happens.
- Bring the two node counts, the two phase splits and the find-in-page result into the chat. I will push back if the throttling preset differs between the two traces, or if the node count is a guess.
What this does not cover
Windowing reduces how many nodes exist. It does nothing about how much work you do per node, per chunk — and re-parsing markdown and re-highlighting a code block on every token is work that windowing happily keeps doing for the handful of messages still on screen. That is the cost-of-parsing-every-token lesson, which closes this module.
It also does not touch render frequency. If your profile named input delay rather than presentation delay, the cause is the length of the render queue in front of the click, and the fix lives in the React module — the keeping-the-stream-out-of-state lesson for the mechanism, the external-store lesson for doing it without fighting React’s model.
And the transcript as a thing with a lifecycle — loading older history, restoring a conversation after a refresh, what a message even is on the wire — is the Streaming interfaces course. Prepending history appears in this lesson only as a property that constrains the scroll math, never as a feature to build here.
Read this next — primary source
Chat UIs Are Lists Until They Aren’tTanStack Blog — free; published 25 May 2026. TanStack describing a problem its own library then solves, which is the clearest kind of vendor bias to hold in mind while reading.
This lesson takes the three properties that make a transcript hostile to windowing, stated in the maintainers’ own words. Read the full post for the reasoning it does not fit here: why prepending history inverts the usual top-anchored model, what the scroll math looks like when you write it yourself, and how the two chat-specific options relate to the rest of the virtualizer’s API. Read it knowing the authors sell the answer — and knowing that a maintainer shipping dedicated APIs for a case is itself evidence the case is hard.
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.