On this page
- Common incomplete constructs
- The simplest viable strategy: reparse the current buffer
- Do not render once per network chunk
- Keep source text as the canonical stream state
- Raw Markdown should not flash during normal streaming
- Unclosed code fences need graceful handling
- Avoid mutating the source to “fix” Markdown
- Code syntax highlighting can dominate CPU
- Stable block identity reduces flicker
- Append-only output makes incremental optimization easier
- Markdown grammar can reclassify previous text
- Tables often “snap” into structure
- Links can remain incomplete for a while
- Images need careful loading behavior
- Math has similar delimiter problems
- Mermaid and diagrams should not regenerate on every token
- Separate parser errors from incomplete syntax
- Final rendering is still important
- Do not change the source between streaming and final rendering
- Scrolling behavior matters as much as parsing
- Streaming code blocks need special scroll behavior
- Selection and copying can conflict with live updates
- Accessibility should receive coherent updates
- Performance model
- Measure before implementing a custom incremental parser
- A practical renderer architecture
- Cache stable expensive blocks
- Keep streaming and final renderer feature parity
- Test prefixes, not just finished fixtures
- Useful fixture corpus
- Where BYOKchat fits
- 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.
Links can remain incomplete for a while
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:
, 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
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.