Topic 2.6
Rendering Tokens Without Jank
In one line
Tokens can arrive faster than a screen can usefully redraw. Keep the update cheap: change only the one message that is growing, draw at most once per frame, never leave the last piece unshown, and draw the cursor with CSS so it is not part of the text.
Think of it like this
A teacher writing on a whiteboard while students copy. If the teacher wipes the whole board and rewrites every line after each new word, the class sees flicker and cannot keep up. If the teacher only adds the new word at the end of the last line, everyone follows easily. Rendering a stream is the same: add the new word, do not redraw the board.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- Jank
- Visible stutter when the page misses frames.
- Frame
- One redraw of the screen. About 60 per second, so about 16 ms each.
- Re-render
- Running a React component again to work out what it should show.
- memo
- A React wrapper that skips re-rendering a component when its props are the same.
- requestAnimationFrame
- A browser function that runs your code just before the next frame is drawn.
- Batching
- Collecting several updates and applying them together.
- Layout shift
- Content jumping because something above it changed size.
- ::after
- A CSS pseudo-element that draws content after an element without adding it to the text.
Step by step
01Measure the waste
The first example shows 30 old messages in two lists. One list uses a plain row component, the other a memo row. It then types 40 tokens into a live line below each list and counts how often rows were rendered. Plain rows: 1200 renders (30 rows times 40 tokens). Memo rows: 0.
02One render per frame
The second example streams 100 tokens and counts renders of the answer element in two modes. In the first, every token sets state. In the second, a pending requestAnimationFrame collects tokens, so there are fewer renders. Both end with all the text shown.
The batch code is short: only schedule a frame if none is pending, and at the end cancel it and set the final text.
let all = "";
let frame = 0;
for await (const piece of pieces) {
all += piece;
if (!frame) frame = requestAnimationFrame(() => { frame = 0; setText(all); });
}
cancelAnimationFrame(frame);
setText(all); // the end: never leave the last piece waiting03The cursor belongs to the style, not the text
The third example renders the same stream twice. With a glyph cursor the character is inside textContent while streaming. With CSS the text stays clean and the style draws it. Both end clean.
To make the CSS cursor blink, animate it, and switch the animation off for readers who ask their system for less motion. The cursor should also disappear when the stream ends: the streaming class is only on while text is arriving.
.answer.streaming::after {
content: "\258D"; /* the block character, drawn by the style: not part of the text */
animation: blink 1s steps(2, start) infinite;
}
@keyframes blink { to { visibility: hidden; } }
@media (prefers-reduced-motion: reduce) {
.answer.streaming::after { animation: none; } /* a steady block is enough */
}04Keep the box steady
Give the answer area a minimum height and keep controls out of the growing text. Mitra's page does this in Topic 2.8: the message box has a fixed height with its own scrollbar, so growing text never moves the input.
Try it yourself
- 1
Remove memo
In the first example the plain list already shows the cost. Change
Row={MemoMessage}toRow={PlainMessage}for the second list too. Both lists now count into the sameplaincounter, so the last line readsafter 40 tokens, row renders: { plain: 2400, memo: 0 }: 1200 for each list. - 2
A faster model
In the second example change
delayMs: 2to0. Per-token mode still renders about once per token, while batching renders even less often. The faster the stream, the more batching saves. - 3
Select the text
In the third example change the last two lines to run only
await run(true);, and while it streams, select the answer with the mouse and copy. The block character is copied too. That is the content leak the CSS cursor avoids.
Code & diagrams
Each token changes only the line below the list, yet without memo every old row is re-rendered 40 times. With real Markdown rendering per row, that waste is far more expensive.
Preview
Console
Nothing logged yet.Expected console output
after the first render: { plain: 30, memo: 30 }
after 40 tokens, row renders: { plain: 1200, memo: 0 }The example logs comparisons, not render counts, because exact counts depend on timing. The important line is the last one: fewer renders, and still all the text.
Preview
Console
Nothing logged yet.Expected console output
per token | final text complete: true
batched | final text complete: true
every token: about one render per token | all text shown: true
per frame: fewer renders | all text shown: trueWith the glyph version, copying the text or reading it with a screen reader includes the block character. With CSS the content stays exactly what the model wrote.
Preview
Console
Nothing logged yet.Expected console output
cursor as a character: in the text while streaming: true | drawn by CSS: false | text when done is clean: true
cursor from CSS: in the text while streaming: false | drawn by CSS: true | text when done is clean: trueBreak it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
Forget the final flush
In the second example, delete the line setText(all); // the last piece must never be left in the buffer.
Myth vs fact
Myth
Faster rendering means calling setState for every token.
Fact
Users cannot see more than one update per frame. Extra renders are invisible work.
Myth
memo fixes everything.
Fact
memo only helps when props are stable. If you pass a new object or function each time, the row still re-renders. Keep props simple and stable.
Myth
A cursor character is harmless.
Fact
It becomes part of the content: copied, read aloud and stored. Draw it with CSS.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
Measure before optimising: open the browser's performance panel while a long answer streams and look for long tasks. If rendering is not a problem on your slowest target phone, keep the simple per-token version.
- ▸
Batching trades a little latency (up to a frame) for much less work. A frame is about 16 ms, so users never notice it.
- ▸
Background tabs pause requestAnimationFrame. The stream keeps arriving, so the batch code above keeps all the text and draws when the tab returns, but your final flush is what makes the end correct.
Remember this
- 1
The problem this solves: A long answer can arrive as hundreds of tokens, often faster than 60 per second. If each token re-renders the whole conversation, the page stutters, typing in the input lags, and phones heat up. This topic shows how to measure the waste and the three small changes that remove it.
- 2
Jank means the page misses frames. Most screens redraw 60 times a second (some phones and monitors do 120 or more), so each frame has roughly 16 milliseconds, or less on a faster screen. If your code spends longer than that on rendering, a frame is skipped and the page feels stuck. Streaming makes rendering happen again and again, so a small waste per token becomes a big one.
- 3
Change only what changed. When a token arrives, only the growing message is different. The 30 earlier messages are identical. In React, setting state in the parent re-renders every child unless you stop it. Wrapping the message component in
memomakes React skip a message whose props did not change. The first example counts: 40 tokens over 30 old messages cause 1200 row renders withoutmemoand 0 with it. - 4
Draw at most once per frame. Tokens may arrive 100 times a second but the screen shows only 60 frames. Rendering more often than that is invisible work. Keep the text in a normal variable, and ask the browser to draw it with
requestAnimationFrame, which runs your function just before the next frame. If three tokens arrive within one frame, one render shows all three. - 5
The last piece must not wait. With batching, the newest text sits in a variable until the next frame. When the stream ends you must draw it yourself, and cancel any pending frame. Forget this and the answer ends a few words short, only when the stream finishes inside a frame. The break-it drill shows it.
- 6
Draw the cursor with CSS. A blinking block at the end of the text is nice. If you add the character
▍to the answer string, it becomes part of the content: it is copied with the text, read by screen readers, and part of what you store. A CSS::afterrule on a streaming class draws the same mark without touching the text. The third example checks both. - 7
Layout shift. Each new line makes the message taller and pushes things below it. Give the message area a sensible minimum height, put the input in a fixed place, and avoid inserting elements above text the reader is looking at. Topic 2.7 handles the scrolling side.
- 8
Very long answers. Setting the entire accumulated string each time costs more as the answer grows. For answers of a few thousand words this is still fine. If you also parse Markdown on every token (Phase 6), that cost grows quickly, which is the reason to render only the last block as live text and settle earlier blocks once.
Explain it without notes
Why can a streamed answer make a page janky?
How do memo and requestAnimationFrame each help?
Why must the last piece be drawn explicitly when batching?
Why draw the cursor with CSS instead of adding a character?
Practice
Write createBatcher(set) that returns { push(text), flush() }. push schedules at most one frame, flush cancels it and applies the latest text at once.
Why does the second example use a local variable all and not setText((t) => t + value) inside the animation frame?
Trade-offs
- ↔
Per-token rendering is the simplest code and fine for short answers. Batching adds a few lines and a flush rule, and pays off for long answers and slow phones.
- ↔
A CSS cursor needs a class and a style rule, a little more setup than a character, and keeps content clean.
Done when you can
I can measure how often a component re-renders.
I can stop old messages re-rendering with memo and stable props.
I can batch updates to one per frame and flush the last piece.
I draw the streaming cursor with CSS, not as text.
I can name causes of layout shift while text grows.