Every AI chat UI hits the same wall. Tokens arrive over SSE, you do content += token, and a computed property calls marked.parse(content) again. For a short answer it works. For a 3,000-token answer with a table, a fenced code block, and a Mermaid diagram, the message area re-renders dozens of times per second. The result is flicker, jumping scroll position, diagrams that vanish until the closing fence arrives, and a main thread that stays busy long after the stream ends.
I maintain markstream-vue, a streaming Markdown renderer built for exactly this case. This post walks through why the usual renderer pipeline struggles with token streams, and what a streaming-first renderer does differently.
The problem isn't Markdown. It's the timing.
marked, markdown-it, and react-markdown are excellent at rendering a finished document. Their contract is: give me valid Markdown, I give you HTML or a component tree.
An LLM stream violates that contract constantly. Mid-stream you have:
- an unclosed
**boldrun, - a code fence with no closing backticks,
- half a table,
-
$$math with no closing delimiter, - a Mermaid block that only becomes parseable at the very end.
If you re-parse and re-render the full string on every token, you pay for the entire document each time. If you pass partial Markdown straight to a finished-document renderer, you get either broken output or a loading state that flashes on every chunk.
What streaming-first actually changes
Markstream keeps the incomplete state as a first-class idea instead of an error case.
1. Two render modes for two different UX goals
| Mode | Config | Best for |
|---|---|---|
| Virtual window (default) | max-live-nodes="220" |
Long answers, docs, chat history — steady scrolling and memory |
| Incremental batches | max-live-nodes="0" |
The "typing" feel — new content flows in small slices |
The virtual window keeps a sliding set of mounted nodes, so a 10,000-block document doesn't mean 10,000 live DOM nodes. Incremental mode disables virtualization and renders in small batches, which is usually what you want for a chat bubble that is actively growing.
2. Heavy blocks settle progressively
Mermaid and KaTeX are optional peers, not bundled dependencies. When they are installed, diagrams can render as soon as the syntax is complete, and update when later chunks change them — rather than blocking the whole message until the stream ends.
Code blocks are the other hot spot. In v2, the enhanced code surface comes from the optional stream-diffs peer; without it you get a plain <pre> fallback. That keeps the default install small and lets you opt into the heavier diff/highlighting surface only where you need it.
3. final is a real state, not a guess
Most flicker bugs come from not knowing whether the stream is done. Markstream takes a final prop. While it is false, the parser assumes unfinished constructs are still loading. When it flips to true, the renderer invalidates the streaming cache and reparses with final semantics, so a trailing $$ or an unclosed fence doesn't stay stuck in a loading state forever.
Quick start
pnpm add markstream-vue
<script setup lang="ts">
import MarkdownRender from 'markstream-vue'
import 'markstream-vue/index.css'
</script>
<template>
<MarkdownRender :content="markdown" />
</template>
That is the whole integration for a read-only surface. For an AI chat message, add the stream state:
<script setup lang="ts">
import { onBeforeUnmount, ref } from 'vue'
import MarkdownRender from 'markstream-vue'
import 'markstream-vue/index.css'
const content = ref('')
const final = ref(false)
let source: EventSource | undefined
function start(url: string) {
source = new EventSource(url)
source.onmessage = (event) => {
content.value += event.data
}
source.addEventListener('done', () => {
final.value = true
source?.close()
})
}
onBeforeUnmount(() => source?.close())
</script>
<template>
<MarkdownRender
:content="content"
:final="final"
:max-live-nodes="0"
:render-batch-size="16"
:render-batch-delay="8"
:fade="false"
:typewriter="true"
/>
</template>
:max-live-nodes="0" selects incremental mode. The batch props control how much work is allowed per frame, which is the difference between a smooth typewriter effect and one that jumps in 2 KB chunks.
If you already parse Markdown on the server or in a worker, you can skip client-side parsing entirely and pass typed nodes:
import { getMarkdown, parseMarkdownToStructure } from 'markstream-vue'
const md = getMarkdown()
const nodes = parseMarkdownToStructure(source, md, { final: true })
<MarkdownRender :nodes="nodes" />
That is also the escape hatch for SSR and hydration: parse once on the server, serialize the nodes, and keep streaming later chunks on the client.
Putting Vue components inside Markdown
This is the part that made the library worth building for me. A chat answer often needs a callout, a button, or a chart — something that isn't expressible in plain Markdown.
setCustomComponents maps a marker to a Vue component:
import { setCustomComponents } from 'markstream-vue'
setCustomComponents('chat', {
CALLOUT: () => import('./Callout.vue'),
})
Then emit the marker in Markdown and scope the renderer:
[[CALLOUT:warning title="Heads up" body="This response was truncated."]]
<MarkdownRender :content="content" custom-id="chat" />
The same mechanism handles custom HTML tags such as thinking, with the original payload kept on the node.
When not to use it
I would rather you pick the right tool:
-
Static docs, blog posts, changelogs —
markedormarkdown-itis simpler and smaller. There is no stream to optimize. - You need every ecosystem plugin — the unified/remark ecosystem is hard to beat for finished documents.
- You want zero optional peers — Markstream works, but Mermaid, KaTeX, and the enhanced code surface are opt-in for a reason.
The value shows up when Markdown is still changing while the user is reading it. That is a different rendering problem from "render this file".
Other frameworks
The same core is available outside Vue: markstream-react (React / Next.js), markstream-solid, markstream-svelte, markstream-angular, markstream-vue2, plus stream-markdown-parser for parsing without a UI layer.
Try it
- Live Vue playground: https://markstream-vue.simonhe.me/
- Shareable repro / test page: https://markstream-vue.simonhe.me/test
- Docs: https://markstream.simonhe.me/frameworks/vue
- GitHub: https://github.com/Simon-He95/markstream-vue
- npm: https://www.npmjs.com/package/markstream-vue
If you have a streaming UI that flickers, the test page lets you paste a repro and share the URL — that is the fastest way to show me the bug.
Prefer the short version first? I wrote an earlier, shorter post on why streaming AI chat needs a renderer for incomplete Markdown: https://dev.to/simonhe95/why-streaming-ai-chat-needs-a-renderer-for-incomplete-markdown-4754
Disclosure: I am the maintainer of markstream-vue, so read the comparison with the usual grain of salt. The library is MIT-licensed, and the optional peers are the only paid-in-bundle-size trade-offs.
Top comments (0)