When a chatbot generates a response, inserting the full text instantly feels mechanical. Real conversations have rhythm — words appear, pauses happen, the listener processes. We implemented progressive streaming rendering in ReLU.chat to bridge this gap.
The Approach
Instead of calling innerHTML = fullResponse, we use requestAnimationFrame to reveal the response in ~40-character chunks:
const stream = pushMessageStream('bot', meta);
const rendered = md(text);
const CHUNK = 40;
let pos = 0;
const reveal = () => {
pos = Math.min(pos + CHUNK, rendered.length);
stream.update(rendered.slice(0, pos));
if (pos < rendered.length) requestAnimationFrame(reveal);
else stream.done();
};
requestAnimationFrame(reveal);
Each frame reveals one chunk. The browser batches these with its paint cycle, so there's no layout thrash. KaTeX math rendering is deferred until the stream completes — rendering math mid-stream would cause jarring reflows.
Why 40 Characters?
We tested chunk sizes from 10 to 100 characters. Below 20, the animation feels sluggish (too many frames). Above 60, it feels instant (no perceived progress). 40 characters is roughly one sentence fragment — enough to feel like natural typing without being slow.
The Reduced Motion Contract
Accessibility is non-negotiable. When prefers-reduced-motion: reduce is set, the response appears instantly:
const prefersReduced = window.matchMedia(
'(prefers-reduced-motion: reduce)'
).matches;
if (prefersReduced) {
stream.update(rendered);
stream.done();
} else {
// progressive reveal
}
The CSS prefers-reduced-motion rule also sets animation-duration: 0.01ms !important on all elements, ensuring no CSS animations play.
The Stream API
pushMessageStream() returns an object with three methods:
update(html)— replaces the message content (incremental)done()— finalizes, triggers KaTeX rendering, scrolls to bottomelement— the DOM element (for diagram attachment)
Performance
For a typical 200-word response (~1000 characters), the stream completes in ~25 frames (~417ms at 60fps). The total DOM cost is 25 innerHTML assignments on the same element — each replacing the previous content with a slightly longer string. The browser's diffing algorithm handles this efficiently.
Key Takeaway
Progressive rendering is a UX improvement that costs almost nothing. No new dependencies, no complex state machines, no virtual DOM. Just requestAnimationFrame and a slice counter.