Smoothing streamed LLM text in React: it looks like a typewriter loop. It isn't.
When a model streams a reply, the text doesn't arrive one character at a time. It arrives in clumps — a couple of words, a pause, then a whole sentence at once. Render each clump as it lands and the output stutters: text jumps, stalls, jumps again. The chat apps you actually like to use hide this. The words glide in at a steady, readable pace, and that smoothness is faked on top of bursty data.
Faking it looks like a five-line typewriter loop. It isn't — at least not if you want it correct across languages, honest about latency, and free of a flash that only shows up when a user hits "regenerate."
The snippets below are distilled from use-smooth-text, a small zero-dependency hook I put on npm. The production code is a little more careful than what's here, but these are the same ideas.
Before the traps: why the browser, not the server
The popular way to get this is the Vercel AI SDK's smoothStream. It works well, with one catch that's easy to miss: it's a server-side transform, and that server has to be JavaScript. It re-chunks the model's output as it passes through your Node process before it reaches the browser.
Many LLM backends aren't Node. They're Python, Go, Rust, or something behind a gateway. If that's you, smoothStream isn't available — the code path doesn't exist on your side.
But smoothing is a rendering concern, not a transport one. By the time text reaches the browser it's just a string, and a string is a string whether it came from FastAPI over SSE or a Node server over a WebSocket. So do the smoothing in the browser, on whatever text has arrived so far. That one decision makes it backend-agnostic — and it's the whole reason the hook is controlled by a plain string you already have:
const { text } = useSmoothText(full, { done })
return <p>{text}</p>
You keep appending chunks to full however you received them. The hook hands back a smoothly-growing slice of it. It never knows your backend exists.
Now the three traps.
Trap 1: "one character at a time" is a lie in most of the world
The obvious reveal is full.slice(0, n), incrementing n. That indexes by UTF-16 code unit, and plenty of characters are more than one code unit. A 👍 is two. A 👨👩👧 family emoji is many, glued together with zero-width joiners. Slice through the middle and you render half a surrogate pair — a broken-glyph "tofu" box — for one frame, every time one passes.
It's worse for whole writing systems. "Reveal a word at a time" by splitting on spaces does nothing useful for Chinese or Japanese, and stepping by code unit mangles combining marks in Arabic and Hebrew.
The browser already knows where the real boundaries are. Intl.Segmenter gives you graphemes (or words, locale-aware) so you only ever cut where a human would:
const seg = new Intl.Segmenter(undefined, { granularity: 'grapheme' })
// boundaries: the character offsets you're allowed to cut at
const boundaries = [0]
for (const { index, segment } of seg.segment(full)) {
boundaries.push(index + segment.length)
}
Reveal up to the largest boundary that's ≤ where you're "supposed" to be, and a half-emoji never reaches the screen.
Trap 2: a fixed speed is either too slow or a lie
The typewriter loop reveals one unit every N milliseconds. Pick a speed and you've picked a problem.
Too slow, and you fall behind a fast model and stay behind — the user finishes reading, the buffer's still draining, the text crawls long after the answer arrived. Too fast, and you're back to rendering the bursts you were trying to smooth out.
The fix is to make the speed depend on how far behind you are. Reveal at a base rate normally, but when a big chunk lands and the backlog grows, speed up enough to clear it within a short window:
const remaining = arrivedChars - revealedChars
const charsPerSec = Math.max(baseRate, remaining / CATCH_UP_SECONDS)
Now it glides during the quiet stretches and accelerates through bursts, landing roughly when the stream does instead of lagging forever.
This is also where the tradeoff becomes visible to users. Smoothing trades a little immediacy for a steadier read. If you under-set the base rate, that trade shows — the smoothed text finishes noticeably after the raw stream, and it looks slower rather than nicer. Set the base rate near your model's actual token throughput and the delay becomes imperceptible; it evens out the cadence rather than adding to it. The knob is there because the right value depends on your model, not because there's a free lunch.
Trap 3: the flash I almost shipped
This one didn't show up in the demo, the tests, or local clicking around. I caught it by accident while testing the regenerate flow: the message gets replaced with a completely different reply, and for a single frame the new text flashed at the wrong length before it corrected itself.
The displayed text is full.slice(0, revealedChars). When full is swapped for new, unrelated text, revealedChars is still the count from the old message. So for one frame you render newText.slice(0, oldCount) — a chunk of the new reply, at the wrong length — before anything corrects it.
My first instinct was to reset revealedChars to 0 in an effect when the text changes. That's too late: effects run after the browser paints, so the wrong frame is already on screen. The actual fix is to do the reset during render, using React's "adjust state during render" pattern, so React re-renders with the corrected value before it ever paints:
// during render, not in an effect
if (prevText !== full) {
setPrevText(full) // always track the latest text
if (isReset(prevText, full)) {
setRevealedChars(0) // only on a real reset — corrected before paint, no flash
}
}
isReset here is just "the new text isn't an extension of the old one" — a normal streamed append ("hel" → "hello") isn't a reset; a regenerate is. The lesson that stuck: when you fix derived state matters as much as whether you fix it. An effect was the intuitive place and the wrong one.
What it deliberately doesn't do
- It smooths the visual reveal, not the network. It can't make a slow server fast — but doing it in the browser is exactly what makes it work everywhere.
- Feed it human-readable text, not tool-call or JSON payloads. You don't want to type out a function-call argument one character at a time.
- It's append-only. It expects the string to grow; a non-prefix change is treated as a new message (see trap 3), not an edit of what's already shown.
- It doesn't parse markdown. For streaming markdown, pair it with a renderer that completes partial syntax; revealing by word instead of character also cuts down on mid-token flicker.
Try it
npm install use-smooth-text
There's a side-by-side demo in the repo that's easier to judge than a GIF — and the package is about 1 kB min+gzip with zero runtime dependencies.
- npm: https://www.npmjs.com/package/use-smooth-text
- GitHub: https://github.com/Maaz046/use-smooth-text
The whole thing started as "this is just a setInterval." It mostly isn't — the interesting parts are the boundaries you're allowed to cut on, the speed you reveal at, and the single frame between a render and an effect.

Top comments (0)