LLM token stream Markdown renderer
An LLM token stream is not complete Markdown. For most of the response lifetime, the text may contain an open code fence, a partial table row, half of a link, or a KaTeX expression with no closing delimiter. A streaming renderer should show useful intermediate states without forcing the app to buffer the whole answer.
Transport-agnostic rendering contract
Markstream only needs two pieces of state:
| State | Meaning |
|---|---|
content | The Markdown accumulated so far |
final | Whether the current answer is complete |
That contract works for SSE, WebSocket, fetch() streams, workers, or any custom token source.
<MarkdownRender
mode="chat"
:content="content"
:final="isDone"
:fade="false"
/>Batch tiny chunks before rendering
Fast streams can emit many tiny chunks. Batch them before committing to Vue state.
import { ref } from 'vue'
const content = ref('')
const pending = ref('')
const isDone = ref(false)
let frameId = 0
export function appendTokenChunk(chunk: string) {
pending.value += chunk
if (frameId)
return
frameId = requestAnimationFrame(() => {
content.value += pending.value
pending.value = ''
frameId = 0
})
}
export function flushTokenChunks() {
if (!pending.value)
return
content.value += pending.value
pending.value = ''
if (frameId) {
cancelAnimationFrame(frameId)
frameId = 0
}
}This keeps the renderer responsive without hiding the stream from users.
Fetch stream example
async function streamMarkdownResponse(response: Response) {
const reader = response.body?.getReader()
const decoder = new TextDecoder()
if (!reader)
return
while (true) {
const { done, value } = await reader.read()
if (done)
break
appendTokenChunk(decoder.decode(value, { stream: true }))
}
const tail = decoder.decode()
if (tail)
appendTokenChunk(tail)
flushTokenChunks()
isDone.value = true
}Set final to true only after the loop finishes, the decoder has been flushed, and any pending buffered text has been committed.
Chunking strategy
| Stream shape | Recommended approach |
|---|---|
| Large chunks every few hundred ms | Append directly to content |
| Tiny chunks many times per second | Batch with requestAnimationFrame |
| Very long response | Enable node virtualization and viewport priority |
| Existing parser worker | Pass pre-parsed nodes instead of raw content |
Common mistakes
- Rendering every byte as a separate Vue state update.
- Setting
final=truebefore the server sends the last chunk. - Buffering the whole answer before showing anything to users.
- Enabling heavy optional peers globally when only a few answers need them.
- Treating WebSocket, SSE, and fetch as different renderer problems instead of different transports.
Next steps
- Building a Vue chat UI? Read Vue AI chat Markdown renderer.
- Using SSE or WebSocket events? Read SSE and WebSocket streaming.
- Need stable open fences and tables? Read Incomplete Markdown renderer.
- Rendering long output? Read Long AI responses.