
Every AI chat app has the same bug. You've felt it. That stuttering scrollbar, the content jumping,...
Every AI chat app has the same bug. You've felt it. That stuttering scrollbar, the content jumping, the dropped frames when tokens stream in. I spent weeks building a library that makes it physically impossible.
Open ChatGPT. Claude. Gemini. Any LLM-powered chat interface.
Now watch the scrollbar while the model streams a response.
See it? That micro-stutter. The scrollbar jumps. The content reflows. If you're on a slower device, you'll see actual frame drops. It's subtle on short responses, but stream 500+ tokens and it becomes infuriating.
Why does this happen?
Every single token that arrives triggers the same cascade:
Token arrives → DOM mutation → Style recalculation → Layout reflow → Paint → Composite
At 50 tokens/second, that's 50 full layout reflows per second. Each one forces the browser to:
On a page with 200 DOM elements, each reflow touches dozens of nodes. The browser's layout engine was never designed for this kind of write-heavy, real-time workload.
The result: Scrollbar jitter. Content jumping. Dropped frames. A "janky" feeling that makes expensive AI products feel cheap.
I asked a simple question: What if we never trigger a single layout reflow?
The answer was <canvas>.
Canvas rendering uses fillText() — a direct pixel operation that happens in the compositor thread. No DOM nodes to measure. No CSS to recalculate. No layout to reflow. Just math → pixels.
But "just use canvas" is like saying "just rewrite everything in Assembly." You lose:
So I built ZeroJitter — a React component that gives you all of those back while keeping the canvas performance.
┌─ Main Thread ──────────────────────────────────┐
│ │
│ LLM tokens → useZeroJitter hook │
│ │ │
│ postMessage() │
│ ▼ │
│ ┌─ Web Worker ────────────────────────┐ │
│ │ Intl.Segmenter → measureText() │ │
│ │ Line breaking • CJK • BiDi • Emoji │ │
│ │ Returns: lines[], height, widths │ │
│ └─────────────────────────────────────┘ │
│ │ │
│ onmessage() │
│ ▼ │
│ CanvasRenderer.paint() → <canvas> │
│ AccessibilityMirror → <div aria-live> │
│ │
└────────────────────────────────────────────────┘
The expensive part of text layout isn't painting pixels — it's measuring text. Every time you add a word, the browser needs to figure out: Does this word fit on the current line? Where does the next line start? How tall is the container now?
ZeroJitter moves ALL of this math to a Web Worker using CanvasRenderingContext2D.measureText(). The worker:
Intl.Segmenter (handles CJK per-character breaking, Thai word boundaries, Arabic/Hebrew BiDi)measureText() callThe main thread then just fillText()s each line at its computed position. Zero layout involvement. Zero reflows. Locked 60fps.
| Metric | DOM Rendering | ZeroJitter |
|---|---|---|
| Reflows per token | 1 | 0 |
| Layout time | 0.3-2ms | <0.01ms |
| Frame drops (@ 100 tok/s) | 12-30 | 0 |
| FPS | 45-58 | 60 (locked) |
| Scrollbar stability | Jittery | Rock solid |
npm install zero-jitter
import { useRef } from 'react';
import { ZeroJitter, useZeroJitter } from 'zero-jitter';
function StreamingChat() {
const ref = useRef<HTMLDivElement>(null);
const { append, clear, layout } = useZeroJitter(ref);
useEffect(() => {
const sse = new EventSource('/api/chat');
sse.onmessage = (e) => append(e.data);
return () => sse.close();
}, [append]);
return (
<ZeroJitter
ref={ref}
font="16px Inter"
maxHeight={400}
color="#e2e8f0"
/>
);
}
That's it. Drop-in replacement. Your streaming goes from janky to buttery.
There are canvas text libraries. ZeroJitter is specifically engineered for streaming:
requestAnimationFrameA visually-hidden <div aria-live="polite"> mirrors the canvas text with a 300ms debounce during streaming. Screen readers announce updates without being flooded by individual tokens.
The entire text layout engine (based on pretext) is vendored into the library. No external runtime dependencies. Just React as a peer dep.
Built on Intl.Segmenter with full support for:
See it yourself: altrusian.com/zero-jitter
The demo streams the same text into both a standard DOM element and a ZeroJitter canvas side-by-side, with real-time metrics. Crank the speed to 150 tok/s and watch the DOM panel fall apart while the canvas stays rock solid.
Layout thrashing isn't a "nice to fix" — it's a trust destroyer.
When users interact with an AI chat app, the streaming response is the primary interface. If that interface stutters, users subconsciously associate the jank with the AI itself. "Is it thinking? Did it freeze? Is something wrong?"
Smooth streaming = perceived intelligence.
Every major AI company is going to need to solve this as models get faster. GPT-4o streams at ~100 tokens/second. The next generation will be 200+. DOM rendering will break completely at those speeds.
ZeroJitter is open source, MIT licensed, and ready for production.
Links:
npm install zero-jitterTL;DR: I built a React library that renders streaming LLM text on <canvas> instead of the DOM. Zero layout reflows, locked 60fps, full accessibility, zero dependencies. The scrollbar will never jitter again.
gemmaI ported the whole Gemma-4 family — E2B, E4B, 12B, 31B, and the 26B-A4B MoE — to run on...
communityHey DEV, I'm Tobore. Let's actually connect. I've been on here for a while now, mostly writing and...
ai(yep, kinda clickbait, just for the funsies 😊) At the beginning of the year, I relaunched my...
aiMy laptop was sitting idle with the fan at full tilt. Nothing was running that I knew of. The culprit...
githubactionsI Built a Thing! TL;DR — Google Gemini-based Pull Request reviews and Issue Triaging for...
aiI've been hearing the word "harness" thrown around a lot lately. I assumed it just meant "the IDE" or...
Workflows from the Neura Market marketplace related to this DeepSeek resource