Streaming UI stability is the part of real-time interfaces nobody plans for. The usual approach is to append each chunk as it arrives and hope the layout survives. AI chat bubble or live log viewer, the reasoning tends to stop once the data reaches the client, and how that data lands in the DOM is left to chance. That is where the bugs live.
Fourteen years of WordPress and front-end work, and the same failure keeps turning up: a stream handled as though it were one static update. The layout jumps, the scroll position snaps back to the bottom while the user is halfway through an older line, and the accessibility tree turns into noise. The causes are specific, and so are the fixes.
The scrolling race condition
The first thing to break is the scroll fight. Your code wants to follow new content down the page; the user wants to scroll up and reread a line from thirty seconds ago. Set scrollTop = scrollHeight on every update and the code wins every time, which means the interface has decided where the user should be looking.
The fix is to track intent. Auto-scroll only when the user is already at the bottom, and detach as soon as they move up. A small threshold keeps a few pixels of layout shift from being read as a deliberate scroll.
let bbioonUserScrolled = false;
const container = document.getElementById('streaming-box');
container.addEventListener('scroll', () => {
// A 60px gap allows for small layout shifts without breaking the "tail"
const gap = container.scrollHeight - container.scrollTop - container.clientHeight;
bbioonUserScrolled = gap > 60;
});
function bbioonAutoScroll() {
if (!bbioonUserScrolled) {
container.scrollTop = container.scrollHeight;
}
}
Managing layout shifts and render frequency
Browsers paint at 60fps. A fast stream can deliver tokens every few milliseconds. Update the DOM on every one of them and the browser recalculates layout far more often than it can paint, which you see as flicker and feel as a hot CPU.
Batch the updates with requestAnimationFrame instead, so the DOM gets touched once per paint cycle rather than once per token. That also clears up cursor flicker, where a blinking caret is destroyed and recreated 80 times a second.
For related animation work, I wrote up killing framework bloat with CSS scroll-driven animations.
Making streaming content accessible
Screen readers have a hard time with streaming content. When a block of text keeps growing, when is a non-sighted user supposed to start reading it? That is what aria-live regions are for, and for sequential updates the log role usually fits better than a generic status role.
The MDN documentation on the log role describes it as intended for regions where new information is added in a meaningful order. Add aria-atomic="false" and the screen reader announces only the new text rather than rereading the entire chat history every time another word arrives.
<!-- The correct way to mark up a streaming container -->
<div id="chat-log"
role="log"
aria-live="polite"
aria-atomic="false"
aria-label="Real-time message log">
</div>
Handling motion sensitivity
Respect the prefers-reduced-motion media query. For a user with a motion sensitivity, the typewriter effect is a barrier rather than a nice touch. If they have asked for reduced motion at the OS level, skip the animation and render the full response at once.
/* Ensure the cursor doesn't blink for users with motion sensitivity */
@media (prefers-reduced-motion: reduce) {
.streaming-cursor {
animation: none;
opacity: 1;
}
}
If streaming UI work is eating your dev hours, I can take it on. I have been building WordPress sites and high-performance interfaces since the 4.x days.
What to fix first
On the backend, streaming is mostly solved. Server-Sent Events and the Streams API hold up fine. The breakage happens on the glass. Track scroll intent, batch DOM updates into the animation frame, mark the container up as a live log region, and the jumpy interface settles down. A stream is a sequence of state changes you manage, not a pile of events you dump into a div.