React streaming-rendering research
Implementation and measured results: streaming performance decision.
Status: research report, 2026-09-10. This assesses the live agent feed as it exists on this branch; it is not an implementation decision. The target case is a visible, continuously appended prose or code preview of at least 1 MiB.
Finding#
The branch's immutable ChunkedText plus StreamingText is the right
rendering shape for live plain text. It preserves sealed block/group
references, makes the final 1 KiB block the only changing text subtree, and
limits the prose reveal to 64 spans. Keep it; do not replace the live path
with a generic streaming-markdown component.
The remaining work is to prove the complete path stays bounded: the text still
has to travel through the pager, reach a narrowly subscribed feed subtree,
lay out in the browser, be measured by the virtual feed, and maintain scroll
position. In particular, a visible 1 MiB pre can make a scrollHeight read
expensive even when React updates only one text node.
Current branch assessment#
What is already good#
chunked-text.ts cuts text
into 1,024 UTF-16-unit blocks and 32-block groups. An append shallow-copies
only the group containing the tail; a full group and all its blocks retain
identity. StreamingText memoizes
both TextGroup and TextBlock, so React shallow comparison can retain
sealed groups and blocks. At 1 MiB, this means approximately 1,024 ordinary
inline spans/text nodes, plus at most 64 transient word spans in the changing
tail, rather than a span per word across the whole preview.
This relies on the same rule React documents for memo: unchanged props must
have the same reference. memo only skips a component when its props compare
equal; a fresh object/array defeats it. React memo
reference
and React useMemo reference
describe that contract. The reducer's immutable append implementation is
therefore an essential correctness requirement for the performance result,
not a cosmetic data-structure choice.
The feed already virtualizes immutable history rows and uses a stable "live" key for the trailing activity. That is the appropriate virtualization boundary: a whole message/activity row can be measured, anchored and restored as one item. The active row remains mounted and dynamically measured, which avoids virtualizer churn while it changes.
The source also already has two useful transport coalescers: 50 ms for the
DO-to-relay pager and 100 ms for the client LiveState engine. This is a
reasonable cadence to test first, rather than rendering provider tokens one
by one.
What it does not solve#
Block identity prevents React reconciliation of old blocks. It does not
make browser text layout free. A visible one-million-character paragraph or
pre still produces many line boxes, and a tail append can extend its height.
The supplied benchmark baseline for an ungrouped 1 MiB prose fixture was
about 150,000 elements, React p95 35 ms/update, and layout 352 ms/update.
The current block renderer should remove the element explosion and most React
work, but only a browser trace can establish its layout cost.
StreamingText still constructs one TextGroup element per group on each
live update. At the current preview ceiling this is about 32 elements for 1
MiB and is not the suspected bottleneck. Do not make block size smaller just
to reduce changing characters: that increases the persistent DOM and line
fragmentation. Measure 1, 4 and 8 KiB blocks if the trace points to the tail;
otherwise retain the simple 1 KiB boundary.
The code preview has a distinct risk. StreamingCodeBlock reads
scrollHeight and writes scrollTop in a layout effect on every update while
pinned. The outer feed's stick-to-bottom observer can also write its own
scrollTop after the row changes. A layout-dependent read after a DOM write
may force a synchronous layout; Chrome documents this failure mode and its
DevTools diagnostic. Chrome DevTools: forced reflow
insight
and Chrome rendering-performance
tools are
the relevant verification tools. This is a measurement concern, not evidence
that the code pane should lose its correct pin/unpin behaviour.
sliceText deliberately materializes all blocks for copy and settled syntax
highlighting. That is right for a user-triggered copy and for once-only
settlement, but it must stay out of every live render path. The live code
component passes ChunkedText directly; preserve that property. Likewise,
do not add a live join, full-string prefix comparison, or full-text Markdown
parse as an animation helper.
What upstream streaming React code actually does#
Publication cadence and external-store snapshots#
Vercel AI SDK's useChat exposes an opt-in throttle for message/data UI
updates. It receives stream events independently, throttles the subscriber
callback before calling React, stores a cached message snapshot, and uses
useSyncExternalStore; its test verifies that several deltas become one
published UI state after 50 ms. The final terminal status publishes the latest
message snapshot before React sees ready.
- AI SDK
useChatoptions, cached snapshot and subscription (pinned source) - AI SDK callback throttling (pinned source)
- AI SDK's 50 ms throttling test (pinned source)
This supports coalescing publication separately from receiving and storing every transport delta. It does not establish a universal magic interval: keep the current 50/100 ms pipeline unless a trace demonstrates a need to change it. If the browser ever receives more than one usable update per frame, add a view-only latest-value rAF coalescer at the final subscription boundary, including an immediate final flush and cancellation on unmount. It must not become a second source of truth or hide a transport backlog.
useSyncExternalStore is appropriate for this bridge only if getSnapshot
returns a cached immutable value. React explicitly warns that returning a new
object each call causes repeated rendering (and can loop); immutable store
data may be returned directly. Official useSyncExternalStore
reference.
Select state.agent.live as narrowly as the existing live-state hook permits,
and retain object identity for presence, runtime and settled rows. A selector
does not itself make the active tail cheap; it prevents unrelated page work.
Markdown components are a different tradeoff#
Vercel's Streamdown parses stream text into Markdown blocks, uses index-stable keys (not content hashes) and memoizes each block, so complete semantic Markdown blocks commonly retain their React subtree.
- Streamdown preprocessing and whole-input block parse (pinned source)
- Its indexed streaming block render (pinned source)
- Its
Blockmemocomparator (pinned source)
That is useful when live rich Markdown is a product requirement. It still
calls parseMarkdownIntoBlocks(processedChildren) over the complete growing
string each update, so it is not a solution for a large, high-rate plain-text
preview. assistant-ui says this directly: its Streamdown wrapper defers the
second parse/render to protect urgent work because Streamdown reparses the
whole accumulated text; its performance contract still expects one primitive
render per token update.
- assistant-ui Streamdown wrapper and its full-reparse comment (pinned source)
- assistant-ui's per-token render contract (pinned source)
For this feed, continue rendering active prose as white-space: pre-wrap and
active code as plain pre; render full Markdown/highlighting only once the
durable text is settled. This gives a better bound than a generic chat UI can
promise while preserving semantic rich rendering for history.
Animation#
assistant-ui's useSmooth separates authoritative target text from a
displayed prefix. A rAF loop drains that prefix; it can cap characters per
frame and independently set a minimum interval between commits, then always
flushes final text and honours reduced motion. Source and options
and the final-flush/reduced-motion path
show a sound view-only pattern.
The branch's smaller strategy is preferable for the agent feed: keep all
sealed text ordinary; animate only the latest append inside the final block;
hard-cap it at 64 spans; let an overly dense whitespace run use one span. Add
prefers-reduced-motion coverage before treating animation as done. Do not
animate code or syntax-highlighted output while it changes. Streamdown itself
also caps its animation backlog at 320 ms to avoid an unbounded invisible
animation queue. Streamdown animation timeline.
React transitions and useDeferredValue can preserve input responsiveness by
allowing a lagging view to be interrupted. They do not make a changed active
tail parse or lay out faster. React recommends them when a slow subtree cannot
otherwise be optimized, and notes that deferred rendering still catches up.
Official useDeferredValue reference
and official startTransition reference.
Use them only after structural work and coalescing have been measured; do not
put a controlled composer input in a transition.
DOM retention, accessibility and copying#
The browser cares about DOM size as well as React time. Chrome's guidance is
to minimize DOM nodes and use containment or content-visibility to isolate
offscreen subtrees. DOM size and interaction
and content-visibility explain
that auto skips offscreen layout/paint while retaining the accessibility
tree, subject to sizing and forced-layout caveats.
Do not apply content-visibility to the visible live tail: it cannot reduce
the required layout. The feed already does better for historical rows by not
mounting them outside its virtual window. Avoid virtualizing inside an active
message merely to hit a node count: it breaks native selection/copy of the
whole text, browser find, and screen-reader traversal unless a separate
complete-copy/download and accessible reading path are designed. A 1 MiB live
preview is an exceptional inspection surface; if tracing says line layout is
still too costly, give the live preview an explicit bounded visible window
with a clear "preview shortened" state and retain the full durable response in
the request inspector.
Do not use an aria-live region for token text. Upstream copy components
announce completion/error rather than the whole transcript. Streamdown copy
button.
Native selection across the current block spans remains useful; the existing
explicit code Copy control should materialize text once on click, as it does.
Scrolling#
The current feed correctly treats bottom following as user intent, keeps the
live row inside the virtualizer's height model, and uses ResizeObserver for
actual growth. Do not replace it with scrollIntoView for every token.
assistant-ui reaches the same conclusion with explicit follow-bottom and
pending-scroll state, cancellable rAF scheduling, and resize-driven
re-pinning. Its auto-scroll implementation.
For the inner code pane, preserve the separate pin state: only follow while within its tolerance of the bottom, release on a real user scroll upward, and do not steal the outer feed's scroll. First profile the present layout-effect read; if it is a long task, schedule at most one post-commit scroll operation per animation frame or let a ResizeObserver trigger it after layout. Verify that the final settled update flushes the scroll state.
Measurement and acceptance plan#
Run each fixture both at desktop and throttled mobile CPU. Produce a Chrome Performance trace and React Profiler export rather than using wall-clock averages alone. The browser Performance API can add marks/measures and observe long tasks; MDN's Performance API reference documents both facilities.
| Fixture | Assertions |
|---|---|
| 1 MiB prose, 1 KiB appends, active at feed bottom | Element count is about block count plus bounded animation spans; sealed blocks do not render; React commit p95/p99 and Layout remain bounded; no long task or growing allocation trend. |
| Same prose while composer receives real keystrokes | Input stays responsive; no unexpected page-wide commits; transition/defer is considered only if this fails after structural fixes. |
| 1 MiB code, inner pane pinned and then manually unpinned | No forced-reflow hot loop; pin follows while selected, never overrides manual inner scrolling; outer feed remains correctly pinned/released. |
| Complete Markdown-like output after settlement | Exactly one replacement from live plain text to settled Markdown/highlighted content; no intermediate full parse. |
| Mid-stream reconnect and terminal flush | No duplicate/missing suffix; latest pending state paints promptly; animation/rAF final state is exact. |
| User selection/copy and reduced motion | Complete visible text copies; code's explicit control succeeds; no token-by-token live announcement; animation is disabled or non-disruptive under reduced motion. |
Chrome's Performance panel can expose forced reflow, long tasks, style/layout time and paint flashing. Instrument source-to-paint cadence separately from transport bytes: a low React commit count does not prove that the relay has stopped serializing whole accumulated text. The companion LiveView report addresses the append transport protocol and pager path.
Recommendation#
- Keep
ChunkedText, 32-block groups, memoized sealed blocks, plain active prose/code and the 64-span tail cap. - Keep the present transport coalescing while measuring it; only introduce a latest-value rAF view adapter if the final subscription can otherwise publish more than once per frame. It must final-flush and expose any backlog.
- Profile the 1 MiB code scroll path before changing it. Address any forced synchronous layout with a single frame-scheduled or resize-triggered write, without weakening the user-intent scroll state machine.
- Make the live feed subscription select the narrowest immutable slice and verify settled rows never render on live text changes.
- Bound the visible preview only if browser layout, rather than React or wire copying, remains the proven bottleneck. Keep complete durable output accessible through history/inspection and retain a deliberate copy path.
Follow-up: mobile inline-layout failure (2026-09-10)#
The first production-shaped browser result changes the priority of the final recommendation. The current immutable blocks are doing their job in React: desktop 1 MiB prose measured p95 React work at 0.3 ms and layout at 12.6 ms, with no long tasks. At a 390 px viewport and 4x CDP CPU slowdown, 1 MiB prose in 16 KiB appends measured p95 React at 1.3 ms but p95 layout at 121 ms, with 48 long tasks and a 250 ms maximum. Code measured p95 layout at 74 ms with 35 long tasks. The bottleneck is browser layout of one growing inline formatting context, not React reconciliation or a per-token animation.
What CSS can and cannot isolate#
contain: layout / contain: content makes a box an independent formatting
context. It is a good fit for an independent paragraph, list, code block or
feed row. The CSS Containment specification
and MDN containment guide
describe this isolation; content-visibility: auto adds skip-rendering for a
box outside the viewport while keeping it in the DOM, selection, find-in-page
and accessibility tree. MDN's relevant-to-user rules
matter here: selection/focus makes a skipped section relevant again.
It cannot independently contain several arbitrary <span> chunks while
preserving one continuous paragraph's line-breaking. The CSS Display
specification says forcing an independent formatting context is impossible/no
op for a non-replaced inline box; an inline-block is an atomic inline box.
CSS Display Level 4
and CSS 2.2's inline-level box definition
establish the constraint. Therefore:
- adding
containorcontent-visibilityto today's inline text-group spans cannot isolate the giant parent inline formatting context; - changing groups to block boxes or atomic inline boxes can isolate their contents, but creates artificial line/block boundaries and changes the rendered paragraph;
display: contentspreserves flow but produces no box on which containment can establish isolation.
Do not ship a character-count block boundary with block containment as a performance "fix." It would silently alter paragraph wrapping and, for block boxes, native selected-text copy semantics.
Viable design now: semantic blocks, then an explicit pathological fallback#
For normal output, split only at real semantic boundaries: paragraph breaks,
Markdown block boundaries, list items where their layout is independently
valid, fenced code blocks, and existing structured result fields. Keep each
settled semantic block immutable and render it as a block box with
contain: content; content-visibility: auto; contain-intrinsic-size: auto 10rem (with a measured fallback appropriate to the content). Its complete
text remains in the DOM and is normally
copyable/searchable/accessibly exposed; once it is above/below the viewport,
the browser can skip its internal layout and paint. This makes actual visible
paragraphs, rather than every preceding paragraph in the 1 MiB response, the
layout scope. contain-intrinsic-size is necessary to avoid a zero-height or
jumping skipped block before it has a remembered size. MDN documents its
relationship to size containment.
This needs a streaming semantic parser that seals only after a valid boundary.
Streamdown shows rich Markdown block memoization is feasible, but its
whole-input parser is unsuitable for the high-rate tail; maintain a small
incremental boundary scanner/parser and keep only its final semantic block
mutable. Plain text can seal on actual blank-line/newline boundaries without
inventing them. Its active final paragraph stays a regular flowing <p> so
wrap and native selection are exact.
A true single 1 MiB paragraph with no semantic boundary is the irreducible
case. No CSS containment arrangement has both independent layout and the
exact same continuous inline formatting. Do not hide it with a 64 KiB cap or
corrupt it with 32 KiB blocks. Make the exception explicit after a measured
threshold: switch that one field to a complete-output viewer with its own
scrolling viewport and an authoritative full-text copy/select-all control.
All text remains available, visible by scrolling, and copyable; it is a
presentation change rather than data loss. A native read-only textarea is a
candidate worth benchmarking because its editing engine owns scrolling and
selection, but it is not a recommendation without the same 390 px trace and
accessibility review. A styled plain <pre> with an inner scroller does not
by itself solve its internal line-layout cost.
If product requirements demand one continuously flowing, richly styled, native-selectable 1 MiB paragraph at phone widths, the remaining route is a purpose-built line virtualizer/renderer. It needs an explicit selection, copy, find and accessibility design; it is not a CSS tweak. Avoid building it until semantic blocks plus the exceptional viewer have been measured.
Revised experiment matrix#
- Prototype semantic paragraphs with containment and run the same 390 px, 4x CPU, 1 MiB/16 KiB fixture. Record visible-tail layout, total DOM nodes, long tasks, scroll anchor and full-document selection/copy.
- Include realistic output with many paragraphs and the adversarial one- paragraph fixture. The former should improve substantially; the latter proves that the fallback criterion is honest.
- Benchmark a read-only native complete-output viewer against the one- paragraph fixture. It must retain all 1 MiB, support keyboard selection and copy, announce sensible label/instructions, and avoid fighting outer-feed scrolling.
- Verify resize/font-size changes. A semantic block can be relaid out when it enters view; its remembered intrinsic size may change, so the feed virtualizer/stick must preserve reader position.