Skip to content

Benchmarks ​

What pacing costs and what it changes, measured on the same streams for every approach. The code, the streams and the raw results are in benchmarks/ in the repository; the tables below are generated from those results.

Method ​

Streams. Two answers were recorded from a real model (Claude, through Claude Code's streaming output) with the time each chunk reached the client. Nine synthetic scenarios reuse the recorded text with arrival patterns chosen to stress one thing each: steady, bursty, a long stall, a fast model, a final burst, a long answer, chunks cut inside emoji and surrogate pairs, chunks cut inside Markdown constructs, and a hidden tab. They are generated from fixed seeds, so every run sees the same streams.

Approaches.

  • as received: every chunk is shown the moment it arrives.
  • fixed 60 cps and fixed 240 cps: a hand-written typing effect, a timer that shows one more character of text.slice(0, count) at a constant rate.
  • AI SDK smoothStream(): the real transform from ai 7.0, with its defaults (word chunks, 10 ms delay), and once with Intl.Segmenter word chunks as its docs suggest for languages without spaces. It is run on the stream before it reaches the client.
  • smooth-stream-text: the core with default settings, and with boundary: 'char' and unit: 'word' to show what those settings change.
  • smoothStream() then smooth-stream-text: both layers stacked.

Pacing metrics are measured in virtual time on a 60 Hz frame clock, so they are exact and repeatable: when each character becomes visible, how evenly the text grows, how long the end takes, and whether any frame shows text a renderer would draw wrongly. Lag and the size of a character are counted in UTF-16 code units, like JavaScript strings. Rendering cost is measured separately, in headless Chromium, replaying streams in real time and rendering Markdown with marked. Bundle size is measured with esbuild, per import.

What the numbers say ​

The cost is a steady delay. With the defaults, the first text appeared 0.08 to 0.15 s after the first chunk of the recorded answers, and half the characters were shown within about 0.2 s of arriving. That is the targetLatencyMs of 180 ms at work. After the stream closed, the rest drained within a second in every scenario.

On the two recorded answers, text grows more evenly than as received: the evenness figure (lower is better) falls by 40 to 55 percent, and the largest amount of text appearing in a single frame drops, from 186 to 105 characters on one answer and from 225 to 16 on the other. The largest remaining step on the English answer is a table row, which the Markdown rules hold until its line is complete.

No frame across all eleven streams ended inside a grapheme cluster. One frame ended inside an open Markdown construct: after the three-second stall, the holdMs safety valve released a held construct, as it is designed to. Rendering as received produced hundreds of such frames, and so did fixed-rate typing.

Pacing does not hide long network pauses. In the bursty scenario, where nothing arrives for up to 0.9 s, the reader still sees most of those pauses: once the backlog has drained, there is nothing left to show. A fixed slow rate hides them better, at the price of falling far behind.

Above 900 characters per second, the default ceiling, the display falls behind and then jumps (the fast scenario). Raise maxCharsPerSecond if your model is faster.

The Markdown rules cost some evenness. With boundary: 'char' the text grows more evenly, but frames with broken syntax appear. The default favours intact syntax.

The AI SDK's smoothStream() evens out the recorded English answer about as well, with less lag. With its default word chunks, text in Chinese or Japanese waits for the next space, so the multilingual answer showed nothing for 0.8 s. With Intl.Segmenter chunks, one segment per 10 ms fell behind on every stream, by up to several seconds. It also produced frames with broken Markdown. Its rows here assume its output reaches the browser exactly as it leaves the transform; in practice it crosses the network again, which can bunch chunks back together.

Fixed-rate typing falls behind real model output by seconds at 240 characters per second and by tens of seconds at 60.

Rendering is the main runtime cost. A paced answer changes on every frame while it appears, so a Markdown renderer runs up to 60 times a second: about twice as often as rendering once per chunk on the recorded answer, and more total rendering time. unit: 'word' changes the text less often. With the reveal, the spans still animating peak at a few hundred on a fast stream and are gone once the answer settles.

These benchmarks found a bug: after a pause inside a held construct, the next chunk appeared all at once. It is fixed in 0.2.1 (see the changelog), and the results below include the fix. They also showed that importing only the React or Vue hooks bundled the reveal as well, about twice the size of the core; that is fixed in 0.2.2.

Results ​

Streams ​

smooth-stream-text 0.2.2 built from v0.2.2, AI SDK ai 7.0.142, 2026-10-16.

StreamOriginCharactersChunksDurationPattern
claude-markdownrecorded26251467.2 srecorded from Claude Code (claude-opus-5-5) stream-json partial messages on 2026-10-16
claude-multilingualrecorded11201056.8 srecorded from Claude Code (claude-opus-5-5) stream-json partial messages on 2026-10-16
burstysynthetic262517219.0 sGroups of 3-8 chunks arrive together, then nothing for 300-900 ms.
fastsynthetic2625441.5 sA fast model: 60-character chunks every 33 ms, about 1,800 characters per second.
hidden-tabsynthetic26253259.8 sThe steady stream, with the tab hidden from 1.5 s to 6 s. Animation frames stop while hidden and timers are throttled to once a second.
longsynthetic1313394633.1 sA long answer: the Markdown text five times over (about 13,000 characters) at about 400 characters per second.
long-stallsynthetic262532612.8 sSteady arrival with a 3-second stall halfway through.
markdown-splitsynthetic262533022.5 sMarkdown with links, bold, inline code and a table, cut at random offsets, with a 400 ms pause after one chunk in eight.
steadysynthetic26253259.8 sEven arrival: 4-12 characters every 30 ms, about 270 characters per second.
tail-burstsynthetic26252087.4 sSteady for 60% of the answer, a 1.2-second pause, then the remaining 40% in one chunk as the stream closes.
unicode-splitsynthetic11202288.0 sMultilingual text with emoji, cut at random UTF-16 offsets, so chunks often end inside a surrogate pair or an emoji sequence.

Recorded streams ​

Real model output as it reached the client. Lag is the time from a character arriving to it being visible. Evenness is the coefficient of variation of characters added per 100 ms while the answer appears: lower is more even. Largest step is the most characters that appeared in one frame. Pauses are gaps of more than 250 ms without new text before the answer is complete. Broken frames end with an unclosed Markdown construct on the last line; split frames end inside a grapheme cluster.

claude-markdown: 2625 characters in 146 chunks over 7.2 s.

ApproachFirst text (ms)Lag p50 (ms)Lag p95 (ms)Tail (ms)EvennessLargest stepPausesBroken framesSplit frames
as received01016131.11863310
fixed 60 cps171831534747365290.04207060
fixed 240 cps171980359737790.06501760
AI SDK smoothStream()057261130.67843170
smoothStream() + Intl.Segmenter02365461046960.272101640
smooth-stream-text150192301790.66105300
smooth-stream-text, boundary 'char'67183286960.481421010
smooth-stream-text, unit 'word'183194314960.67105300
smoothStream() then smooth-stream-text1672464712630.63105100

claude-multilingual: 1120 characters in 105 chunks over 6.8 s.

ApproachFirst text (ms)Lag p50 (ms)Lag p95 (ms)Tail (ms)EvennessLargest stepPausesBroken framesSplit frames
as received061652.91225800
fixed 60 cps17648811705126220.2210154
fixed 240 cps173167888720.7556036
AI SDK smoothStream()8001144224221.5981700
smoothStream() + Intl.Segmenter037582210550.65102010
smooth-stream-text831813563051.3316800
smooth-stream-text, boundary 'char'831813563051.3316800
smooth-stream-text, unit 'word'800832352381.720700
smoothStream() then smooth-stream-text8503166556550.8810600

Text integrity across all streams ​

Totals over all eleven streams. Every approach delivered the complete text, and every frame was a prefix of it.

ApproachFrames with broken MarkdownFrames splitting a grapheme clusterComplete text
as received66535all streams
fixed 60 cps9183309all streams
fixed 240 cps231385all streams
AI SDK smoothStream()2630all streams
smoothStream() + Intl.Segmenter229143all streams
smooth-stream-text10all streams
smooth-stream-text, boundary 'char'16490all streams
smooth-stream-text, unit 'word'10all streams
smoothStream() then smooth-stream-text00all streams

Synthetic scenarios ​

Each scenario stresses one pattern. Open a row for the full table.

bursty: Groups of 3-8 chunks arrive together, then nothing for 300-900 ms.
ApproachFirst textLag p50Lag p95Lag maxTailEvennessLargest step (chars)PausesBroken MDSplit
as received451515112.411442990
fixed 60 cps4127502391025161251610.04207060
fixed 240 cps42054606063110.794161830
AI SDK smoothStream()470388904111.688426130
smoothStream() + Intl.Segmenter42675977992940.7114151780
smooth-stream-text70131404948941.471052100
smooth-stream-text, boundary 'char'371723474372770.967191560
smooth-stream-text, unit 'word'10488621948941.661052400
smoothStream() then smooth-stream-text1042546711068941.221051800
fast: A fast model: 60-character chunks every 33 ms, about 1,800 characters per second.
ApproachFirst textLag p50Lag p95Lag maxTailEvennessLargest step (chars)PausesBroken MDSplit
as received071415150.21600100
fixed 60 cps0211744021442331423310.04207060
fixed 240 cps047749031951595150.08501750
AI SDK smoothStream()014072331238123310.28980200
smoothStream() + Intl.Segmenter05174999710381103810.282101650
smooth-stream-text67874117812118151.25742000
smooth-stream-text, boundary 'char'34848117812118151.187430350
smooth-stream-text, unit 'word'84874117812278151.23745000
smoothStream() then smooth-stream-text11715742464259825650.28105000
hidden-tab: The steady stream, with the tab hidden from 1.5 s to 6 s. Animation frames stop while hidden and timers are throttled to once a second.
ApproachFirst textLag p50Lag p95Lag maxTailEvennessLargest step (chars)PausesBroken MDSplit
as received3134020450004.512071360
fixed 60 cps3213833674738450384500.32517070
fixed 240 cps350505633572757000.64811780
AI SDK smoothStream()3704020453004.481199290
smoothStream() + Intl.Segmenter326434350483036173.9490211270
smooth-stream-text137109742204680831.34105200
smooth-stream-text, boundary 'char'701097422047101831.32901800
smooth-stream-text, unit 'word'187109742204680671.31105200
smoothStream() then smooth-stream-text1531127422047102171.36105200
long: A long answer: the Markdown text five times over (about 13,000 characters) at about 400 characters per second.
ApproachFirst textLag p50Lag p95Lag maxTailEvennessLargest step (chars)PausesBroken MDSplit
as received158151770.212002520
fixed 60 cps32928581765701858251858230.022035340
fixed 240 cps15107922062021675216570.04508770
AI SDK smoothStream()15235725870.348911100
smoothStream() + Intl.Segmenter15146432837029592295730.242108900
smooth-stream-text1151702504201230.48112100
smooth-stream-text, boundary 'char'651722032771900.12905430
smooth-stream-text, unit 'word'1481702504201230.5107100
smoothStream() then smooth-stream-text1151882755202070.52114400
long-stall: Steady arrival with a 3-second stall halfway through.
ApproachFirst textLag p50Lag p95Lag maxTailEvennessLargest step (chars)PausesBroken MDSplit
as received3101717170.61121870
fixed 60 cps3155372939031047310330.04207060
fixed 240 cps33476076476170.47511780
AI SDK smoothStream()327773030170.74842250
smoothStream() + Intl.Segmenter39231823188718500.41211870
smooth-stream-text1371702731543670.85105410
smooth-stream-text, boundary 'char'701732232772000.56611730
smooth-stream-text, unit 'word'1701702731543670.9105510
smoothStream() then smooth-stream-text13719331731132170.85105400
markdown-split: Markdown with links, bold, inline code and a table, cut at random offsets, with a 400 ms pause after one chunk in eight.
ApproachFirst textLag p50Lag p95Lag maxTailEvennessLargest step (chars)PausesBroken MDSplit
as received08171701.261437890
fixed 60 cps096922016721650216500.04207060
fixed 240 cps0672424081330.894291900
AI SDK smoothStream()02540860001.358437240
smoothStream() + Intl.Segmenter01086008001330.7619212010
smooth-stream-text1332005581108831.27105900
smooth-stream-text, boundary 'char'672002833922500.79802860
smooth-stream-text, unit 'word'1832085581108831.291051500
smoothStream() then smooth-stream-text15022560811081331.291051100
steady: Even arrival: 4-12 characters every 30 ms, about 270 characters per second.
ApproachFirst textLag p50Lag p95Lag maxTailEvennessLargest step (chars)PausesBroken MDSplit
as received37171700.23120860
fixed 60 cps3169503233034033340330.04207060
fixed 240 cps35671150124312330.07501760
AI SDK smoothStream()3279033000.43841250
smoothStream() + Intl.Segmenter316573533362736170.231801950
smooth-stream-text137170273473830.61105300
smooth-stream-text, boundary 'char'701732132771830.17601590
smooth-stream-text, unit 'word'187170277473830.62105300
smoothStream() then smooth-stream-text1531903206072170.62105300
tail-burst: Steady for 60% of the answer, a 1.2-second pause, then the remaining 40% in one chunk as the stream closes.
ApproachFirst textLag p50Lag p95Lag maxTailEvennessLargest step (chars)PausesBroken MDSplit
as received37171773.3610501650
fixed 60 cps3167103419036373363730.04207060
fixed 240 cps32733823437343730.27511800
AI SDK smoothStream()3401257132313230.79981200
smoothStream() + Intl.Segmenter313504907534053400.282201840
smooth-stream-text1371978078078071.46348300
smooth-stream-text, boundary 'char'701878078078071.3334711160
smooth-stream-text, unit 'word'18719380712378071.31330300
smoothStream() then smooth-stream-text1372331373155715570.89105300
unicode-split: Multilingual text with emoji, cut at random UTF-16 offsets, so chunks often end inside a surrogate pair or an emoji sequence.
ApproachFirst textLag p50Lag p95Lag maxTailEvennessLargest step (chars)PausesBroken MDSplit
as received118161720.45170035
fixed 60 cps1150851014610686106860.05200155
fixed 240 cps1116579520.3540049
AI SDK smoothStream()6123254715190.8575200
smoothStream() + Intl.Segmenter1133168230360.3290033
smooth-stream-text771772372782020.296000
smooth-stream-text, boundary 'char'771772372782020.296000
smooth-stream-text, unit 'word'2611772362911860.313000
smoothStream() then smooth-stream-text1272043867152190.398100

Rendering cost in a browser ​

Chromium 156.0.8078.4 (headless), Apple M1 Pro, 10 cores, 16 GB, darwin, 2026-10-16. Streams replayed in real time; Markdown rendered with marked into innerHTML. Renders and render time count calls to the Markdown renderer (or appends for plain text). Peak elements is the most elements inside the output at once; with the reveal these are the spans still animating.

StreamApproachRendersRender time (ms)Long tasksFrame p95 (ms)Peak elements
claude-markdownmarkdown as received14660016.890
claude-markdownmarkdown paced291103016.790
claude-markdownmarkdown paced, unit word23977016.790
claude-markdownplain text as received1462016.80
claude-markdownplain text paced + revealn/an/a016.7298
fastmarkdown as received4426016.790
fastmarkdown paced11138016.890
fastmarkdown paced, unit word10235016.790
fastplain text as received441016.70
fastplain text paced + revealn/an/a016.7390
longmarkdown as received946753116.7450
longmarkdown paced15161116216.7450
longmarkdown paced, unit word1283917016.8450
longplain text as received94614016.70
longplain text paced + revealn/an/a016.8209

Bundle size ​

smooth-stream-text 0.2.2, esbuild 0.28.2, minified, ESM, target es2020, react/vue/svelte external, 2026-10-16.

ImportMinifiedgzipbrotli
core: createSmoothStream9.6 kB4.2 kB3.9 kB
react: useSmoothText10.8 kB4.7 kB4.4 kB
react: SmoothText19.1 kB7.8 kB7.3 kB
react: everything21.5 kB8.7 kB8.0 kB
dom: createSmoothText17.1 kB7.0 kB6.5 kB
vue: useSmoothStream10.6 kB4.6 kB4.3 kB
svelte: smoothStream10.1 kB4.4 kB4.1 kB
web-component19.9 kB7.9 kB7.4 kB

Limits of this benchmark ​

  • Two recorded answers from one model and one network path. Other providers and networks produce other rhythms. Record your own with node scripts/record-claude-trace.mjs or write a trace file by hand.
  • Synthetic scenarios are designed, not sampled from traffic. Use them to see how an approach reacts to a pattern, not as typical numbers.
  • "Broken Markdown" is a heuristic on the last visible line (unclosed inline code, emphasis, strikethrough or link). It does not run a full parser.
  • Browser numbers depend on the machine. Compare rows from the same run.
  • Pacing runs in virtual time: it measures the controller, not timer jitter in a busy page.

Reproduce ​

sh
git clone https://github.com/crmapache/smooth-stream-text
cd smooth-stream-text && npm install && npm run build
cd benchmarks && npm install
npm run traces     # rebuilds the synthetic streams from fixed seeds
npm run bench      # pacing, browser, size, then results/summary.md

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