Skip to content

Latency and completion ​

Pacing trades a small, steady delay for an even flow. This page explains where that delay comes from, how long the end of an answer takes, and how to control both. The benchmarks put numbers on it for recorded streams.

How the rate is chosen ​

Every frame the controller computes the rate that would drain the current backlog over the target latency, then eases the actual rate towards it:

ts
const desired = pending / targetLatencyMs
const alpha = 1 - Math.exp(-dt / adaptMs)
rate += (desired - rate) * alpha

So the display runs roughly targetLatencyMs (180 ms by default) behind the text that has arrived. A fast producer builds a bigger backlog and is drained faster; a slow one is drained slower. A twenty-word burst becomes a short acceleration rather than a wall of text, because the rate is smoothed, not the backlog. The rate is held between minCharsPerSecond (12) and maxCharsPerSecond (900).

Pacing never makes the model faster. It decides how much of what already arrived is visible.

Three different "done"s ​

MomentWho knowsWhat it means
Transport doneYour codeThe last chunk has arrived. Pass done: true (or call end()).
Pacing donephase === 'done'All received text is visible.
Reveal settledonSettled, settled, sst:doneThe last unit has finished its fade-in. Only with the reveal.

They happen in that order, a little apart. Show copy buttons or the next prompt on pacing done when you render text yourself, or on settled when you use SmoothText, createSmoothText or the Web Component.

The tail ​

When the transport closes the answer, the target latency decays to zero over settleMs (400 ms), so the tail accelerates into a stop instead of crawling the last few words. An answer is complete at most twice settleMs after it was closed; past that deadline the rest is shown at once.

The same happens on a stream that is still open but silent: after idleGraceMs (200 ms) without input the tail drains, so text never sits waiting for a chunk that is slow to come. When input resumes, pacing resumes.

done may be set before the last chunk arrives. That chunk is still shown and paced, then the answer closes again.

Big backlogs jump ​

If the backlog could not be drawn within maxLatencyMs (2000 ms) even at the maximum rate, the display jumps to the end instead of catching up slowly. This covers two common cases:

  • A whole message handed over at once, for example a cached answer.
  • A background tab that was away long enough. Browsers stop requestAnimationFrame in hidden tabs. When the reader comes back, a backlog of more than about 1,800 characters (two seconds at 900 per second) jumps to the end; a smaller one is caught up at the maximum rate, which takes at most two seconds. With the reveal, only the last maxAnimatedUnits (64) units of a big batch animate.

Show all ​

flush() shows everything received so far, at once. On a stream that is still open, text that arrives afterwards is paced again. If a "Show all" button should remove the delay for the rest of the answer, flush on every new chunk until the answer ends; the React guide has the pattern.

Two smoothing layers add up ​

Server-side smoothing such as the AI SDK's smoothStream() delays chunks before they leave the server. Client-side pacing then holds the display behind what arrives. With both, the reader waits for both. Pick one layer. The comparison helps decide which.

Tuning ​

  • Snappier: lower targetLatencyMs (for example 100). Bursts are then less smooth.
  • Smoother: raise targetLatencyMs (for example 250) or adaptMs.
  • Whole words instead of characters: unit: 'word'.
  • Faster tail: lower settleMs.

Every option can change at runtime with setOptions, or as new props in React and Vue. useSmoothStream (React) and subscribe (core) expose pending and charsPerSecond if you want to watch the controller work; the playground charts both live.

MIT licensed. Plain-text docs for tools and assistants: llms.txt, llms-full.txt, or append .md to any page URL.