BYOKchat Blog

How to Render Markdown While AI Is Still Streaming

Design a stable streaming Markdown renderer that handles incomplete syntax, code fences, tables, math, links, incremental updates, and final re-rendering without flicker.

· 2 min read

On this page
  1. Common incomplete constructs
  2. The simplest viable strategy: reparse the current buffer
  3. Do not render once per network chunk
  4. Keep source text as the canonical stream state
  5. Raw Markdown should not flash during normal streaming
  6. Unclosed code fences need graceful handling
  7. Avoid mutating the source to “fix” Markdown
  8. Code syntax highlighting can dominate CPU
  9. Stable block identity reduces flicker
  10. Append-only output makes incremental optimization easier
  11. Markdown grammar can reclassify previous text
  12. Tables often “snap” into structure
  13. Links can remain incomplete for a while
  14. Images need careful loading behavior
  15. Math has similar delimiter problems
  16. Mermaid and diagrams should not regenerate on every token
  17. Separate parser errors from incomplete syntax
  18. Final rendering is still important
  19. Do not change the source between streaming and final rendering
  20. Scrolling behavior matters as much as parsing
  21. Streaming code blocks need special scroll behavior
  22. Selection and copying can conflict with live updates
  23. Accessibility should receive coherent updates
  24. Performance model
  25. Measure before implementing a custom incremental parser
  26. A practical renderer architecture
  27. Cache stable expensive blocks
  28. Keep streaming and final renderer feature parity
  29. Test prefixes, not just finished fixtures
  30. Useful fixture corpus
  31. Where BYOKchat fits
  32. Further reading

Streaming Markdown is harder than rendering finished Markdown because the parser is constantly looking at an incomplete document.

The model may currently have produced:

Here is the function:

```swift
func lo

A few milliseconds later that same document becomes:

Here is the function:

```swift
func load() async throws {

Then later:

Here is the function:

```swift
func load() async throws {
    // ...
}

If the UI shows raw Markdown until the stream ends, the chat feels broken. If it reparses and relayouts the entire answer on every tiny token, the app can stutter badly.

The goal is:

> Render the best valid interpretation of the current partial document, update efficiently, and do one authoritative final render when the response completes.

## Finished Markdown and streaming Markdown have different assumptions

A normal Markdown renderer receives a complete string:

```text
parse → syntax tree → render

A streaming renderer repeatedly receives prefixes:

"# Tit"
"# Title\n\nHere is **bo"
"# Title\n\nHere is **bold** and `co"
...

Every prefix may contain unfinished syntax.

Common incomplete constructs

Streaming can stop temporarily inside:

  • emphasis: **bo;
  • inline code: `pri;
  • fenced code blocks: `````swift` without closing fence;
  • links: [OpenAI](https://exa;
  • images;
  • headings;
  • blockquotes;
  • ordered/unordered lists;
  • tables;
  • HTML tags;
  • math delimiters;
  • Mermaid fences;
  • nested lists;
  • escape sequences.

The renderer must tolerate these prefixes without throwing, flashing raw markup, or deleting large areas of already-rendered output.

The simplest viable strategy: reparse the current buffer

For many chat messages, the most robust approach is:

append delta to source buffer
→ debounce/coalesce briefly
→ parse entire current Markdown buffer
→ render

This sounds inefficient, but it has major advantages:

  • one parser semantics for partial and final content;
  • no hand-maintained incremental Markdown grammar;
  • edits caused by late-closing delimiters are naturally handled;
  • easy correctness model.

For moderate response sizes, performance can be excellent if updates are coalesced rather than triggered per token.

Do not render once per network chunk

Network/protocol events can be tiny and frequent.

Bad pipeline:

1 delta arrives
→ parse Markdown
→ syntax highlight
→ layout
→ database write
→ repeat 30 times/second

Better:

stream events append to buffer immediately
→ schedule UI update at controlled cadence
→ render latest accumulated source

For example, update roughly once per display frame or every small interval depending on platform performance.

The exact cadence should be measured, not hard-coded from folklore.

Keep source text as the canonical stream state

Do not reconstruct the final answer from rendered nodes.

Maintain:

sourceMarkdown += delta;

Then derive rendered output from that source.

This makes it easy to:

  • copy raw/final content;
  • retry rendering;
  • switch renderer versions;
  • export Markdown;
  • recover after app relaunch.

Raw Markdown should not flash during normal streaming

A poor implementation often does:

streaming → plain Text(source)
completed → MarkdownRenderer(source)

The visual flip is jarring:

**bold**

suddenly becomes:

bold

Use the Markdown renderer during streaming too. The renderer may treat incomplete syntax as literal text temporarily, but it should do so within the same styled document pipeline.

Unclosed code fences need graceful handling

Suppose the current source is:

```python
print("hello")

with no closing fence yet.

Most CommonMark-style parsers can still interpret the rest as a fenced code block to EOF.

That is useful for streaming because the code can render immediately.

Your renderer should test this behavior explicitly rather than adding ad hoc fence-closing strings that may alter semantics.

Avoid mutating the source to “fix” Markdown

A tempting trick is:

if odd number of ``` fences:
    append temporary ```

or:

if unmatched **:
    append **

This can create wrong parses because Markdown syntax is context-sensitive.

If you need a stabilization layer, keep it separate from canonical source and make transformations narrow/tested.

Prefer a parser that already handles incomplete documents reasonably.

Code syntax highlighting can dominate CPU

Even if Markdown parsing is cheap, highlighting a growing 500-line code fence over and over can be expensive.

Possible strategies:

  • highlight only on coalesced updates;
  • cache highlighted blocks whose source has not changed;
  • treat the currently open code block specially;
  • delay expensive highlighting until a block is stable;
  • use incremental lexer support if your renderer has it;
  • final full highlighting after completion.

Do not disable Markdown entirely just because syntax highlighting is costly.

Stable block identity reduces flicker

If every reparse creates brand-new UI nodes, scroll position and layout can jump.

A renderer can derive stable block identity from:

block order
source range
block type
persistent parser node identity where available

Then unchanged earlier blocks can remain visually stable while only the active tail changes.

Conceptually:

paragraph 1   stable
code block 1  stable
paragraph 2   stable
last list     changing

Most streaming edits occur near the end of the message because model output is append-only.

Exploit that property.

Append-only output makes incremental optimization easier

Unlike a text editor, the model normally does not modify earlier emitted characters.

That means you can optimize around a moving tail:

stable prefix | active suffix

A sophisticated renderer can keep parsed blocks in the stable prefix and reparse only the suffix plus enough preceding context to preserve grammar correctness.

But do not build this complexity until profiling shows whole-buffer parsing is insufficient.

Markdown grammar can reclassify previous text

Even append-only input can change how earlier characters are interpreted.

Example:

[OpenAI]

might later be followed by a reference definition.

Tables, lists, and block continuations can also change structure with subsequent lines.

So a streaming incremental parser needs a rollback/reparse window. You cannot always freeze every earlier character forever.

Tables often “snap” into structure

Current partial text:

| Model | Cost |

may initially render as a paragraph.

After:

| --- | --- |

it becomes a table header.

That visual change is correct. The goal is not zero change; it is stable, understandable change without raw-markdown flicker.

A stream can contain:

See [the docs](https://developer.exam

Do not make partially typed URLs clickable.

Only create link interactions once the parser recognizes a complete valid link node.

Also apply URL safety rules independently:

  • allow expected schemes;
  • reject dangerous/custom schemes unless intentionally supported;
  • avoid executing model-generated URLs automatically.

Images need careful loading behavior

If Markdown image syntax is supported, a model can stream:

![diagram](https://...

Only begin image loading after a complete image node exists.

Remote images introduce privacy/network behavior beyond Markdown rendering, so many chat clients disable arbitrary remote images by default or require user interaction.

Math has similar delimiter problems

Inline math:

$E = mc^

or block math:

$$
\int_0^

is incomplete during streaming.

Your math plugin should fail soft. It should not throw the whole renderer into an error state because a delimiter has not arrived yet.

A practical pattern:

partial parse fails for current math node
→ render literal/current source for that node
→ retry next update

Mermaid and diagrams should not regenerate on every token

A Mermaid fence may grow for many seconds.

Rendering a diagram after every delta is expensive and visually noisy.

A better rule is:

while fenced diagram block is open → render source/code placeholder
when block closes → render diagram
on final response → authoritative diagram render

This preserves streaming responsiveness without repeatedly invoking a diagram engine.

Separate parser errors from incomplete syntax

An incomplete stream is expected.

A renderer should distinguish:

valid partial Markdown
expected incomplete construct
unsupported extension
actual renderer/parser bug

Do not log every unclosed fence or math delimiter as an application error.

That would flood telemetry with normal streaming states.

Final rendering is still important

When a generation completes:

  1. flush pending decoder/stream state;
  2. build the final source string;
  3. run the authoritative Markdown parse;
  4. render expensive extensions fully;
  5. update accessibility tree;
  6. persist final rendering metadata/cache if your app uses it.

The final render is your chance to resolve any temporary streaming compromises.

Do not change the source between streaming and final rendering

A bug-prone architecture uses:

streaming source transformation A
final source transformation B

Then users see content change unexpectedly at completion.

Keep the canonical Markdown identical. Only rendering strategy should become more complete/expensive at the end.

Scrolling behavior matters as much as parsing

A smooth renderer can still feel bad if the scroll view jumps.

Typical policy:

if user is near bottom and has not manually scrolled away
→ follow new content
else
→ preserve user's position

Do not force-scroll to bottom on every delta after the user has intentionally scrolled up.

Streaming code blocks need special scroll behavior

A growing code block can become taller than the viewport.

If it has horizontal scrolling, repeated layout can cause horizontal position resets unless identity is stable.

Test:

  • long lines;
  • code fence language arriving after opening marker;
  • nested backticks;
  • copy button visibility;
  • horizontal scroll persistence;
  • code block closing during stream.

Selection and copying can conflict with live updates

If the user selects text while the renderer rebuilds, selection may disappear.

Possible mitigations:

  • keep stable underlying text nodes when possible;
  • avoid full view replacement;
  • reduce render cadence while selection is active;
  • allow copy from final/partial source buffer separately.

This is a UI framework problem, not a Markdown grammar problem, but streaming exposes it quickly.

Accessibility should receive coherent updates

Screen readers do not benefit from every token being announced individually.

Consider accessibility semantics such as:

  • mark generation as busy;
  • announce meaningful completed chunks rather than every character;
  • announce completion once;
  • preserve heading/list/code semantics in the rendered tree.

Test with real assistive technologies instead of relying only on visual behavior.

Performance model

The cost per update is roughly:

parse current source
+ transform plugins
+ syntax highlight changed blocks
+ diff/render UI
+ layout

If source length is n and full parse is O(n), doing it for every tiny token can trend toward quadratic total work across a long generation.

Coalescing updates dramatically reduces the number of parses.

Measure before implementing a custom incremental parser

Profile:

message size
parse time
highlight time
layout time
render update frequency
main-thread utilization

You may find that:

full reparse every ~50 ms

is entirely adequate even for long messages.

A custom incremental Markdown parser is a significant maintenance burden and can introduce correctness differences from final rendering.

A practical renderer architecture

Diagram illustrating the surrounding section

The stream layer should deliver semantic text deltas, not raw SSE chunks.

Cache stable expensive blocks

Good cache candidates include:

  • closed code fences;
  • completed math blocks;
  • completed Mermaid diagrams;
  • stable table blocks.

Key caches by source content plus renderer/version/settings.

Invalidate only when the source range changes.

Keep streaming and final renderer feature parity

If the final renderer supports:

  • tables;
  • task lists;
  • math;
  • Mermaid;
  • syntax highlighting;

but the streaming renderer supports only plain paragraphs, users see a large visual flip at completion.

Aim for the same grammar during streaming, with only expensive finalization deferred when necessary.

Test prefixes, not just finished fixtures

Take representative Markdown documents and test every prefix.

For a source of length N:

source[0:1]
source[0:2]
...
source[0:N]

You do not need to snapshot every pixel, but verify:

  • parser never crashes;
  • renderer never hangs;
  • no unsafe link/image action fires from incomplete syntax;
  • final prefix renders identically to ordinary final rendering.

This is one of the most effective streaming renderer tests.

Useful fixture corpus

Include:

  • nested lists;
  • long code fences;
  • code containing triple backticks;
  • tables;
  • blockquotes;
  • links and reference links;
  • escaped punctuation;
  • Unicode;
  • inline/block math;
  • Mermaid;
  • HTML if supported;
  • very long unbroken strings;
  • malformed Markdown;
  • mixed reasoning + answer channels rendered separately.

Where BYOKchat fits

A local multi-provider client can normalize provider deltas into one answer buffer and run the same Markdown renderer during and after streaming. The UI should never depend on provider-specific SSE chunking, and expensive features such as syntax highlighting or diagrams can be stabilized at the rendering layer rather than by weakening stream semantics.

This keeps the chat visually native while preserving correct Markdown source for export and history.

Further reading

Keep reading