Skip to content

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:

StateMeaning
contentThe Markdown accumulated so far
finalWhether the current answer is complete

That contract works for SSE, WebSocket, fetch() streams, workers, or any custom token source.

vue
<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.

ts
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

ts
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 shapeRecommended approach
Large chunks every few hundred msAppend directly to content
Tiny chunks many times per secondBatch with requestAnimationFrame
Very long responseEnable node virtualization and viewport priority
Existing parser workerPass pre-parsed nodes instead of raw content

Common mistakes

  • Rendering every byte as a separate Vue state update.
  • Setting final=true before 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

FAQ

Should every token from an LLM stream trigger a Markdown render?

No. Buffer small chunks and commit them at animation-frame or short interval cadence so parsing and layout stay predictable.

Does Markstream require a specific transport?

No. The renderer receives content or nodes, so the same rendering path works with SSE, WebSocket, fetch streams, or a custom transport.

What does final do in a token stream?

final marks the point where the answer is complete, allowing incomplete fences, tables, math, and heavy blocks to settle into their final rendering.