<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: JAI</title>
    <description>The latest articles on DEV Community by JAI (@jvoltci).</description>
    <link>https://dev.to/jvoltci</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F123548%2Fd0c902b0-0eed-4a44-b4d2-e2dd30cee4f9.jpg</url>
      <title>DEV Community: JAI</title>
      <link>https://dev.to/jvoltci</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/jvoltci"/>
    <language>en</language>
    <item>
      <title>I Made Streaming Markdown 300x Faster — Here's the Architecture</title>
      <dc:creator>JAI</dc:creator>
      <pubDate>Mon, 30 Mar 2026 12:17:14 +0000</pubDate>
      <link>https://dev.to/jvoltci/i-made-streaming-markdown-300x-faster-heres-the-architecture-4a9f</link>
      <guid>https://dev.to/jvoltci/i-made-streaming-markdown-300x-faster-heres-the-architecture-4a9f</guid>
      <description>&lt;p&gt;Every AI chat app has the same hidden performance bug.&lt;/p&gt;

&lt;p&gt;Go open ChatGPT. Stream a long response. Open DevTools → Performance tab → Record.&lt;/p&gt;

&lt;p&gt;Watch the flame chart. Every single token triggers a &lt;strong&gt;full re-parse&lt;/strong&gt; of the entire accumulated markdown string. Every heading re-detected. Every code block re-highlighted. Every table re-measured.&lt;/p&gt;

&lt;p&gt;After 500 tokens on a 2KB response, your app has re-parsed &lt;strong&gt;1,000,000 characters&lt;/strong&gt;. The work scales quadratically.&lt;/p&gt;

&lt;p&gt;I built &lt;a href="https://github.com/jvoltci/stream-md" rel="noopener noreferrer"&gt;StreamMD&lt;/a&gt; to make this structurally impossible. Here's how.&lt;/p&gt;




&lt;h2&gt;
  
  
  🔴 The O(n²) Trap
&lt;/h2&gt;

&lt;p&gt;Here's the code every AI app uses:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;Chat&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;streamingText&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Re-parses ALL markdown, re-renders ALL components — per token&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;ReactMarkdown&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;streamingText&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;ReactMarkdown&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This looks innocent. But here's what actually happens on &lt;strong&gt;every token&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Token arrives
  → Concat to string (now 2,847 chars)
  → Re-parse ENTIRE string from char 0
  → Rebuild AST (unified/remark/rehype)
  → Diff entire virtual DOM tree
  → Reconcile all changed nodes
  → Re-highlight all code blocks
  → Re-measure all tables
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;At token 1, you parse 5 characters. At token 100, you parse 400 characters. At token 500, you parse 2,000 characters. &lt;strong&gt;Every. Single. Time.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The total characters processed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;5 + 10 + 15 + ... + 2,000 = ~500,000 characters
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's O(n²). And it gets worse the longer the response.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why nobody notices
&lt;/h3&gt;

&lt;p&gt;At 15 tok/s (GPT-3.5 speed), the browser can keep up. You burn CPU, but it's fast enough.&lt;/p&gt;

&lt;p&gt;At 50+ tok/s (modern models), frames start dropping. Code blocks flicker as they're re-highlighted. Tables visibly rebuild. The scrollbar jitters.&lt;/p&gt;

&lt;p&gt;At 100+ tok/s (where we're headed), it falls apart entirely.&lt;/p&gt;




&lt;h2&gt;
  
  
  🟢 The Fix: Incremental Block Parsing
&lt;/h2&gt;

&lt;p&gt;I asked one question: &lt;strong&gt;what if the parser only processed new characters?&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;StreamMD&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;stream-md&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;stream-md/styles.css&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;Chat&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;streamingText&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;StreamMD&lt;/span&gt; &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;streamingText&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;theme&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"dark"&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same API. Same output. Completely different internals.&lt;/p&gt;

&lt;h3&gt;
  
  
  How StreamMD's parser works
&lt;/h3&gt;

&lt;p&gt;The &lt;code&gt;StreamParser&lt;/code&gt; class accepts the full accumulated text on each call. But internally, it tracks &lt;code&gt;prevLength&lt;/code&gt; and &lt;strong&gt;only processes the delta&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fullText&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;ParseResult&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fullText&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;prevLength&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="c1"&gt;// Only process NEW characters&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;newContent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;fullText&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;prevLength&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;prevLength&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;fullText&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="c1"&gt;// Parse new lines into blocks&lt;/span&gt;
  &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;buffer&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;newContent&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;lines&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;buffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="c1"&gt;// ... classify each line into block types&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each line is classified into a block type:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Heading&lt;/strong&gt; — starts with &lt;code&gt;#&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Code fence&lt;/strong&gt; — starts with &lt;code&gt;&lt;/code&gt;&lt;code&gt;&lt;/code&gt;`&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Table&lt;/strong&gt; — contains &lt;code&gt;|&lt;/code&gt; pipes&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;List&lt;/strong&gt; — starts with &lt;code&gt;-&lt;/code&gt;, &lt;code&gt;*&lt;/code&gt;, &lt;code&gt;1.&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Blockquote&lt;/strong&gt; — starts with &lt;code&gt;&amp;gt;&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Paragraph&lt;/strong&gt; — everything else&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;When a block is complete (the parser encounters a blank line, a new heading, or a closing code fence), it's marked &lt;code&gt;closed: true&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  The React layer
&lt;/h3&gt;

&lt;p&gt;Here's where it gets good. Each block is rendered by a &lt;code&gt;React.memo&lt;/code&gt; component:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;&lt;/code&gt;&lt;code&gt;tsx&lt;br&gt;
const BlockContent = React.memo(function BlockContent({ block }) {&lt;br&gt;
  switch (block.type) {&lt;br&gt;
    case 'heading': return &amp;lt;HeadingBlock block={block} /&amp;gt;;&lt;br&gt;
    case 'code':    return &amp;lt;CodeBlock block={block} /&amp;gt;;&lt;br&gt;
    case 'table':   return &amp;lt;TableBlock block={block} /&amp;gt;;&lt;br&gt;
    // ...&lt;br&gt;
  }&lt;br&gt;
});&lt;br&gt;
&lt;/code&gt;&lt;code&gt;&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Closed blocks never re-render.&lt;/strong&gt; Their props don't change, so &lt;code&gt;React.memo&lt;/code&gt; skips them entirely.&lt;/p&gt;

&lt;p&gt;On each token, only &lt;strong&gt;one component re-renders&lt;/strong&gt; — the active (last, unclosed) block. Everything above it is frozen.&lt;/p&gt;




&lt;h2&gt;
  
  
  🧠 The Hard Part: Incomplete Lines
&lt;/h2&gt;

&lt;p&gt;Here's the bug that took the longest to fix.&lt;/p&gt;

&lt;p&gt;When tokens arrive mid-line, you get partial content:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;&lt;/code&gt;&lt;code&gt;plaintext&lt;br&gt;
Token 1: "## He"     ← Not a complete heading yet&lt;br&gt;
Token 2: "ading\n"   ← NOW it's complete&lt;br&gt;
&lt;/code&gt;&lt;code&gt;&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;The naive approach commits &lt;code&gt;"## He"&lt;/code&gt; to the active block. When &lt;code&gt;"ading\n"&lt;/code&gt; arrives, the parser sees the full line &lt;code&gt;"## Heading"&lt;/code&gt; and processes it again. &lt;strong&gt;Duplicate text.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;StreamMD's fix: &lt;strong&gt;incomplete lines live in a separate buffer.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;&lt;/code&gt;`typescript&lt;br&gt;
push(fullText: string) {&lt;br&gt;
  // ...&lt;br&gt;
  const lines = this.buffer.split('\n');&lt;/p&gt;

&lt;p&gt;// Last segment has no trailing \n — it's incomplete&lt;br&gt;
  const incompleteLine = this.buffer.endsWith('\n') &lt;br&gt;
    ? '' &lt;br&gt;
    : lines.pop()!;&lt;/p&gt;

&lt;p&gt;// Only process COMPLETE lines&lt;br&gt;
  for (const line of lines) {&lt;br&gt;
    this.processLine(line);&lt;br&gt;
  }&lt;/p&gt;

&lt;p&gt;// Store incomplete line separately&lt;br&gt;
  this._incompleteLine = incompleteLine;&lt;br&gt;
  this.buffer = incompleteLine;&lt;br&gt;
}&lt;br&gt;
`&lt;code&gt;&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;The incomplete line is &lt;strong&gt;never committed to block content&lt;/strong&gt;. Instead, it's virtually appended at render time:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;&lt;/code&gt;&lt;code&gt;tsx&lt;br&gt;
// In the React component&lt;br&gt;
if (incompleteLine &amp;amp;&amp;amp; activeBlock) {&lt;br&gt;
  // Display block = real content + pending text (read-only view)&lt;br&gt;
  const displayContent = activeBlock.content + '\n' + incompleteLine;&lt;br&gt;
  return &amp;lt;BlockContent block={{ ...activeBlock, content: displayContent }} /&amp;gt;;&lt;br&gt;
}&lt;br&gt;
&lt;/code&gt;&lt;code&gt;&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;This means the parser state is always clean. No duplication. No corruption. The incomplete text is a temporary visual overlay that gets replaced by the real content when the line completes.&lt;/p&gt;




&lt;h2&gt;
  
  
  📊 The Numbers
&lt;/h2&gt;

&lt;p&gt;I built a &lt;a href="https://altrusian.com/stream-md" rel="noopener noreferrer"&gt;live demo&lt;/a&gt; with a side-by-side comparison. Here's what it shows for a typical LLM response (~1,300 characters, 15 blocks):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;react-markdown&lt;/th&gt;
&lt;th&gt;StreamMD&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Chars parsed&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;~400,000&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;~1,300&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Per-token complexity&lt;/td&gt;
&lt;td&gt;O(n) — full re-parse&lt;/td&gt;
&lt;td&gt;O(1) — delta only&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Block re-renders per token&lt;/td&gt;
&lt;td&gt;All blocks&lt;/td&gt;
&lt;td&gt;1 (active only)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bundle size&lt;/td&gt;
&lt;td&gt;45kB + remark + rehype&lt;/td&gt;
&lt;td&gt;30kB total&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Runtime dependencies&lt;/td&gt;
&lt;td&gt;unified + remark + rehype + ...&lt;/td&gt;
&lt;td&gt;0 (React peer only)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Syntax highlighting&lt;/td&gt;
&lt;td&gt;BYO (Prism 40kB / Shiki 200kB)&lt;/td&gt;
&lt;td&gt;Built-in (3kB, 15 langs)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;300x fewer characters processed.&lt;/strong&gt; Same formatted output.&lt;/p&gt;




&lt;h2&gt;
  
  
  💻 Usage
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Drop-in replacement
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;&lt;/code&gt;&lt;code&gt;bash&lt;br&gt;
npm install stream-md&lt;br&gt;
&lt;/code&gt;&lt;code&gt;&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;&lt;/code&gt;`tsx&lt;br&gt;
import { StreamMD } from 'stream-md';&lt;br&gt;
import 'stream-md/styles.css';&lt;/p&gt;

&lt;p&gt;// That's it. One component.&lt;br&gt;
&lt;br&gt;
`&lt;code&gt;&lt;/code&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  With Vercel AI SDK
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;&lt;/code&gt;`tsx&lt;br&gt;
'use client';&lt;br&gt;
import { useChat } from '@ai-sdk/react';&lt;br&gt;
import { StreamMD } from 'stream-md';&lt;br&gt;
import 'stream-md/styles.css';&lt;/p&gt;

&lt;p&gt;export default function Chat() {&lt;br&gt;
  const { messages, input, handleInputChange, handleSubmit } = useChat();&lt;/p&gt;

&lt;p&gt;return (&lt;br&gt;
    &lt;/p&gt;
&lt;br&gt;
      {messages.map((m) =&amp;gt; (&lt;br&gt;
        &lt;br&gt;
          {m.role === 'assistant' ? (&lt;br&gt;
            &lt;br&gt;
          ) : (&lt;br&gt;
            &lt;p&gt;{m.content}&lt;/p&gt;
&lt;br&gt;
          )}&lt;br&gt;
        &lt;br&gt;
      ))}&lt;br&gt;
      &lt;br&gt;
        &lt;br&gt;
      &lt;br&gt;
    &lt;br&gt;
  );&lt;br&gt;
}&lt;br&gt;
`&lt;code&gt;&lt;/code&gt;
&lt;h3&gt;
  
  
  Hook API (advanced)
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;&lt;/code&gt;`tsx&lt;br&gt;
import { useStreamMD } from 'stream-md';&lt;/p&gt;

&lt;p&gt;function CustomRenderer() {&lt;br&gt;
  const { blocks, activeIndex, incompleteLine, push, reset } = useStreamMD();&lt;/p&gt;

&lt;p&gt;useEffect(() =&amp;gt; {&lt;br&gt;
    const sse = new EventSource('/api/chat');&lt;br&gt;
    let text = '';&lt;br&gt;
    sse.onmessage = (e) =&amp;gt; {&lt;br&gt;
      text += e.data;&lt;br&gt;
      push(text);&lt;br&gt;
    };&lt;br&gt;
    return () =&amp;gt; sse.close();&lt;br&gt;
  }, [push]);&lt;/p&gt;

&lt;p&gt;return (&lt;br&gt;
    &lt;/p&gt;
&lt;br&gt;
      {blocks.map((block, i) =&amp;gt; (&lt;br&gt;
        &lt;br&gt;
          {/* Frozen blocks will never re-render thanks to React.memo */}&lt;br&gt;
        &lt;br&gt;
      ))}&lt;br&gt;
    &lt;br&gt;
  );&lt;br&gt;
}&lt;br&gt;
`&lt;code&gt;&lt;/code&gt;
&lt;h3&gt;
  
  
  Component overrides
&lt;/h3&gt;

&lt;p&gt;Full control — swap any element with your own component:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;&lt;/code&gt;&lt;code&gt;tsx&lt;br&gt;
&amp;lt;StreamMD&lt;br&gt;
  text={text}&lt;br&gt;
  components={{&lt;br&gt;
    pre: ({ code, language }) =&amp;gt; &amp;lt;MyCodeBlock code={code} lang={language} /&amp;gt;,&lt;br&gt;
    a: ({ href, children }) =&amp;gt; &amp;lt;MyLink href={href}&amp;gt;{children}&amp;lt;/MyLink&amp;gt;,&lt;br&gt;
    table: ({ headers, rows }) =&amp;gt; &amp;lt;MyTable headers={headers} rows={rows} /&amp;gt;,&lt;br&gt;
  }}&lt;br&gt;
/&amp;gt;&lt;br&gt;
&lt;/code&gt;&lt;code&gt;&lt;/code&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  🎨 What's Included
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Markdown support:&lt;/strong&gt;&lt;br&gt;
Headings, paragraphs, code blocks (fenced), inline code, bold, italic, links, images, ordered/unordered/task lists, tables with alignment, blockquotes, horizontal rules, strikethrough.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Syntax highlighting:&lt;/strong&gt;&lt;br&gt;
Built-in lightweight highlighter (~3kB) for JavaScript, TypeScript, Python, Rust, Go, Java, C/C++, Bash, JSON, HTML, CSS, SQL, YAML, Diff, Markdown. No Prism. No Shiki. No extra bundle.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Theming:&lt;/strong&gt;&lt;br&gt;
Dark and light presets via CSS custom properties. Or bring your own — set &lt;code&gt;theme="none"&lt;/code&gt; and override &lt;code&gt;--smd-*&lt;/code&gt; variables.&lt;/p&gt;




&lt;h2&gt;
  
  
  🔗 The Stack: ZeroJitter + StreamMD
&lt;/h2&gt;

&lt;p&gt;StreamMD has a companion library: &lt;a href="https://github.com/jvoltci/zero-jitter" rel="noopener noreferrer"&gt;ZeroJitter&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;&lt;/code&gt;&lt;code&gt;plaintext&lt;br&gt;
zero-jitter   → plain text streaming (canvas rendering, zero DOM reflows)&lt;br&gt;
stream-md     → markdown streaming (incremental parsing, block memoization)&lt;br&gt;
&lt;/code&gt;&lt;code&gt;&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;ZeroJitter&lt;/strong&gt; eliminates layout thrashing by rendering text to &lt;code&gt;&amp;lt;canvas&amp;gt;&lt;/code&gt; via a Web Worker. It's for raw text streams where you don't need markdown formatting.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;StreamMD&lt;/strong&gt; eliminates redundant parsing by incrementally tracking blocks. It's for full markdown rendering with headings, code blocks, tables, and inline formatting.&lt;/p&gt;

&lt;p&gt;Together, they own the "streaming LLM display" category. Use the right tool for the job.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Takeaway
&lt;/h2&gt;

&lt;p&gt;The performance problem in AI chat apps isn't React. It isn't the DOM. It's &lt;strong&gt;re-parsing content that hasn't changed&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;StreamMD doesn't make React faster. It makes React do less work. Completed blocks are frozen. Only the active block updates. The parser only sees new characters.&lt;/p&gt;

&lt;p&gt;The fastest code is the code that never runs.&lt;/p&gt;




&lt;p&gt;📦 &lt;code&gt;npm install stream-md&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;⭐ &lt;a href="https://github.com/jvoltci/stream-md" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;🎮 &lt;a href="https://altrusian.com/stream-md" rel="noopener noreferrer"&gt;Live Demo&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Built by &lt;a href="https://altrusian.com" rel="noopener noreferrer"&gt;Jai&lt;/a&gt;. Feedback and contributions welcome.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>react</category>
      <category>javascript</category>
      <category>webdev</category>
      <category>ai</category>
    </item>
    <item>
      <title>I Eliminated Layout Jitter From LLM Streaming — Here's How</title>
      <dc:creator>JAI</dc:creator>
      <pubDate>Mon, 30 Mar 2026 08:15:13 +0000</pubDate>
      <link>https://dev.to/jvoltci/zerojitter-stop-layout-thrashing-stream-llm-tokens-without-jitter-36ef</link>
      <guid>https://dev.to/jvoltci/zerojitter-stop-layout-thrashing-stream-llm-tokens-without-jitter-36ef</guid>
      <description>&lt;p&gt;&lt;strong&gt;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.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  The Problem Nobody Talks About
&lt;/h2&gt;

&lt;p&gt;Open ChatGPT. Claude. Gemini. Any LLM-powered chat interface.&lt;/p&gt;

&lt;p&gt;Now watch the scrollbar while the model streams a response.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why does this happen?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Every single token that arrives triggers the same cascade:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Token arrives → DOM mutation → Style recalculation → Layout reflow → Paint → Composite
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;At 50 tokens/second, that's &lt;strong&gt;50 full layout reflows per second&lt;/strong&gt;. Each one forces the browser to:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Recalculate every CSS property that could be affected&lt;/li&gt;
&lt;li&gt;Recompute the geometry of every element in the render tree&lt;/li&gt;
&lt;li&gt;Determine what pixels need repainting&lt;/li&gt;
&lt;li&gt;Composite the final frame&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The result:&lt;/strong&gt; Scrollbar jitter. Content jumping. Dropped frames. A "janky" feeling that makes expensive AI products feel cheap.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Nuclear Option: Bypass the DOM Entirely
&lt;/h2&gt;

&lt;p&gt;I asked a simple question: &lt;strong&gt;What if we never trigger a single layout reflow?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The answer was &lt;code&gt;&amp;lt;canvas&amp;gt;&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Canvas rendering uses &lt;code&gt;fillText()&lt;/code&gt; — 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.&lt;/p&gt;

&lt;p&gt;But "just use canvas" is like saying "just rewrite everything in Assembly." You lose:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Text selection&lt;/li&gt;
&lt;li&gt;Accessibility (screen readers)&lt;/li&gt;
&lt;li&gt;Responsive reflow on resize&lt;/li&gt;
&lt;li&gt;Line breaking&lt;/li&gt;
&lt;li&gt;International text support (CJK, BiDi, Thai)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So I built &lt;strong&gt;ZeroJitter&lt;/strong&gt; — a React component that gives you all of those back while keeping the canvas performance.&lt;/p&gt;

&lt;h2&gt;
  
  
  Architecture: How ZeroJitter Works
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;┌─ Main Thread ──────────────────────────────────┐
│                                                │
│  LLM tokens → useZeroJitter hook               │
│                    │                            │
│              postMessage()                      │
│                    ▼                            │
│  ┌─ Web Worker ────────────────────────┐       │
│  │ Intl.Segmenter → measureText()      │       │
│  │ Line breaking • CJK • BiDi • Emoji  │       │
│  │ Returns: lines[], height, widths     │       │
│  └─────────────────────────────────────┘       │
│                    │                            │
│              onmessage()                        │
│                    ▼                            │
│  CanvasRenderer.paint() → &amp;lt;canvas&amp;gt;              │
│  AccessibilityMirror  → &amp;lt;div aria-live&amp;gt;         │
│                                                │
└────────────────────────────────────────────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  The Key Insight: Measurement ≠ Rendering
&lt;/h3&gt;

&lt;p&gt;The expensive part of text layout isn't painting pixels — it's &lt;em&gt;measuring text&lt;/em&gt;. 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?&lt;/p&gt;

&lt;p&gt;ZeroJitter moves ALL of this math to a Web Worker using &lt;a href="https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/measureText" rel="noopener noreferrer"&gt;&lt;code&gt;CanvasRenderingContext2D.measureText()&lt;/code&gt;&lt;/a&gt;. The worker:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Segments text&lt;/strong&gt; via &lt;code&gt;Intl.Segmenter&lt;/code&gt; (handles CJK per-character breaking, Thai word boundaries, Arabic/Hebrew BiDi)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Measures each segment&lt;/strong&gt; via an OffscreenCanvas &lt;code&gt;measureText()&lt;/code&gt; call&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Caches measurements&lt;/strong&gt; — the word "the" at 16px Inter always has the same width&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Performs line breaking&lt;/strong&gt; with pure arithmetic (~0.0002ms per text block)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Returns line data&lt;/strong&gt; to the main thread&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The main thread then just &lt;code&gt;fillText()&lt;/code&gt;s each line at its computed position. Zero layout involvement. Zero reflows. Locked 60fps.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Numbers
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;DOM Rendering&lt;/th&gt;
&lt;th&gt;ZeroJitter&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Reflows per token&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Layout time&lt;/td&gt;
&lt;td&gt;0.3-2ms&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;&amp;lt;0.01ms&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Frame drops (@ 100 tok/s)&lt;/td&gt;
&lt;td&gt;12-30&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;FPS&lt;/td&gt;
&lt;td&gt;45-58&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;60&lt;/strong&gt; (locked)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Scrollbar stability&lt;/td&gt;
&lt;td&gt;Jittery&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Rock solid&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Usage
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install &lt;/span&gt;zero-jitter
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;useRef&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;react&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;ZeroJitter&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;useZeroJitter&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;zero-jitter&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;StreamingChat&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ref&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;useRef&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;HTMLDivElement&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;append&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;clear&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;layout&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useZeroJitter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ref&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="nf"&gt;useEffect&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sse&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;EventSource&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/api/chat&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;sse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;onmessage&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;sse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;append&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;

  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;ZeroJitter&lt;/span&gt;
      &lt;span class="na"&gt;ref&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;ref&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="na"&gt;font&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"16px Inter"&lt;/span&gt;
      &lt;span class="na"&gt;maxHeight&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="mi"&gt;400&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="na"&gt;color&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"#e2e8f0"&lt;/span&gt;
    &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's it. Drop-in replacement. Your streaming goes from janky to buttery.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Makes This Different
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Not "just a canvas text renderer"
&lt;/h3&gt;

&lt;p&gt;There are canvas text libraries. ZeroJitter is specifically engineered for &lt;strong&gt;streaming&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Token coalescing&lt;/strong&gt;: Multiple tokens arriving in the same frame are batched into one worker message via &lt;code&gt;requestAnimationFrame&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stale response discarding&lt;/strong&gt;: Monotonic request IDs ensure out-of-order worker responses don't cause glitches&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Incremental layout&lt;/strong&gt;: Only remeasures changed text, not the entire document&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Viewport culling&lt;/strong&gt;: O(log n) binary search — only visible lines are painted, even for 10,000-line documents&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Full accessibility
&lt;/h3&gt;

&lt;p&gt;A visually-hidden &lt;code&gt;&amp;lt;div aria-live="polite"&amp;gt;&lt;/code&gt; mirrors the canvas text with a 300ms debounce during streaming. Screen readers announce updates without being flooded by individual tokens.&lt;/p&gt;

&lt;h3&gt;
  
  
  Zero dependencies
&lt;/h3&gt;

&lt;p&gt;The entire text layout engine (based on &lt;a href="https://github.com/chenglou/pretext" rel="noopener noreferrer"&gt;pretext&lt;/a&gt;) is vendored into the library. No external runtime dependencies. Just React as a peer dep.&lt;/p&gt;

&lt;h3&gt;
  
  
  International text
&lt;/h3&gt;

&lt;p&gt;Built on &lt;code&gt;Intl.Segmenter&lt;/code&gt; with full support for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;CJK (Chinese, Japanese, Korean) — per-character line breaking with kinsoku rules&lt;/li&gt;
&lt;li&gt;Arabic/Hebrew — BiDi text with correct segment ordering&lt;/li&gt;
&lt;li&gt;Thai — proper word segmentation (Thai has no spaces!)&lt;/li&gt;
&lt;li&gt;Emoji — corrects Chrome/Firefox canvas emoji width inflation&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Live Demo
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;See it yourself:&lt;/strong&gt; &lt;a href="https://altrusian.com/zero-jitter" rel="noopener noreferrer"&gt;altrusian.com/zero-jitter&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Deeper Problem
&lt;/h2&gt;

&lt;p&gt;Layout thrashing isn't a "nice to fix" — it's a &lt;strong&gt;trust destroyer&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;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?"&lt;/p&gt;

&lt;p&gt;Smooth streaming = perceived intelligence.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;ZeroJitter is open source, MIT licensed, and ready for production.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Links:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;📦 npm: &lt;a href="https://www.npmjs.com/package/zero-jitter" rel="noopener noreferrer"&gt;&lt;code&gt;npm install zero-jitter&lt;/code&gt;&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;🔗 GitHub: &lt;a href="https://github.com/jvoltci/zero-jitter" rel="noopener noreferrer"&gt;github.com/jvoltci/zero-jitter&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;🎮 Live Demo: &lt;a href="https://altrusian.com/zero-jitter" rel="noopener noreferrer"&gt;altrusian.com/zero-jitter&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;🌐 Website: &lt;a href="https://altrusian.com/zero-jitter" rel="noopener noreferrer"&gt;altrusian.com/zero-jitter&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; I built a React library that renders streaming LLM text on &lt;code&gt;&amp;lt;canvas&amp;gt;&lt;/code&gt; instead of the DOM. Zero layout reflows, locked 60fps, full accessibility, zero dependencies. The scrollbar will never jitter again.&lt;/p&gt;

</description>
      <category>llm</category>
      <category>performance</category>
      <category>showdev</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
