Streaming Markdown Without a Broken UI
A streamed answer is unfinished most of the time. Learn to render partial Markdown, code blocks, tables, and citations without flicker or unsafe HTML.
Interview answer
Keep the raw streamed Markdown as the source of truth, render partial syntax gracefully, memoize completed blocks, and sanitize model-generated content exactly like user-generated content.
Rendering a finished Markdown string is easy. Rendering one that is still arriving is a different job. Half a code fence, an incomplete table row, or a link whose closing parenthesis has not arrived yet are all perfectly normal states in a streaming answer.
The useful mental model: the assistant message is temporarily invalid source text, not a finished document. Your UI must stay readable while it is incomplete, then settle into its final form without jumping, flashing, or changing already-read content.
01Keep source text as the durable state
Store the raw Markdown delta stream as the message source of truth. Do not store a partly-built React tree or append HTML directly into the DOM. When the stream finishes, the exact same source can be rendered again, copied, persisted, and rehydrated after a refresh.
type AssistantMessage = {
id: string;
markdown: string; // every received delta, in order
status: 'streaming' | 'complete' | 'interrupted';
};
Render the current markdown with a Markdown renderer on each batched update. It is fine if a parser treats unfinished syntax as plain text for a moment. It is not fine if your data model loses the original answer while trying to be clever.
02Incomplete syntax is a normal state, not an exception
Imagine these three deltas arriving one after another:
1. Try this:
```tsx
const Card = () =>
2. <div>Hello</div>;
3.
```
Done.
After delta 1, the user should already see an in-progress code block. Delta 2 extends that same block; delta 3 closes it and adds prose. The copy button should keep its identity throughout. An unfinished table can fall back to readable lines until it becomes a valid table, and an unfinished link can remain text until its destination is complete.
Prefer graceful fallback over a pile of token-level special cases. If your parser cannot safely render a construct yet, show its source in the current message rather than guessing at a structure that may change on the next chunk.
Waiting for a closing code fence makes the answer appear to freeze right when the user expects progress. Render partial content progressively; only hold back a tiny trailing fragment when your own streaming transport requires it.
03Avoid flicker: stable blocks beat a full re-parse
Re-parsing a long answer for every token can make syntax highlighting flicker and can re-render hundreds of already-complete blocks. First batch incoming deltas to an animation frame and measure. If the answer is long enough to need more work, split it at confirmed block boundaries and memoize the closed prefix. Keep the uncertain tail live: a paragraph may still turn into a table or continue a list when another line arrives.
const blocks = splitMarkdownIntoBlocks(markdown);
const { closed, tail } = partitionAtConfirmedBoundary(blocks);
// Cache closed blocks; re-render tail as new deltas arrive.
This is a rendering optimization, not a different data model. Keep one raw source string, derive blocks from it, and profile before adding a sophisticated incremental parser.
04Try the stream, not just the finished answer
A final Markdown snapshot can pass while the experience is broken. Write a test that feeds the three deltas above in order and inspects the UI after each one. Check that code is readable before the closing fence, the copy control does not remount, and the final text has one code block followed by “Done.” Repeat with an incomplete link, a table header that gains its separator row, cancellation mid-fence, and a refreshed page restored from raw source.
If you are explaining this in an interview, walk through one of those intermediate frames. It shows why “just use a Markdown renderer” is only the starting point.
05Code blocks need a different contract from prose
Users copy code while it is streaming. Give each code block a stable copy button, preserve whitespace with a monospace renderer, and delay expensive syntax highlighting until the block is complete if highlighting makes typing janky. A plain but stable in-progress code block is better than a rainbow block that redraws every 30ms.
Never execute model-generated code merely because it rendered in a code block. If your product offers a preview, run it in a sandbox with an explicit user action and clear resource limits.
06Make the intermediate frames feel calm
Rendering correctness is only half the exercise. A response that repeatedly moves the scroll position, steals focus, or announces every token to a screen reader is technically streaming but unpleasant to use. Keep focus on the composer or the control the user chose. Auto-scroll only while the reader is already near the bottom; if they scroll up, let them read.
For assistive technology, announce a short status such as “Assistant is responding,” then announce a completed answer or batched meaningful chunks. Do not place a rapidly changing token stream in an assertive live region. If the stream is interrupted, retain the partial source with a clear “Interrupted” label and a retry action. That makes recovery honest instead of erasing what the user already saw.
Try a slow simulated stream, select text inside a code block, and keep typing in the composer. If selection jumps or keystrokes lag, that is a concrete signal to reduce rerenders or highlighting work.
07Markdown is untrusted input
Markdown can contain links, images, HTML-like payloads, and deceptive text. Render through an allowlist, sanitize any HTML path, and validate outbound URLs before opening them. Do not let a model response inject arbitrary tags, inline event handlers, or a browser URL with sensitive query data.
Keep the source, make each intermediate frame readable, and treat the final render as untrusted content too.
Key Takeaways
- 01A streaming message is temporarily invalid Markdown; render it progressively instead of waiting for a finished document.
- 02Keep raw Markdown as the durable source of truth and derive rendered blocks from it.
- 03Memoize only blocks with confirmed boundaries; keep the uncertain tail live.
- 04Use readable fallbacks for incomplete tables and links; never guess a structure that may change with the next delta.
- 05Markdown from a model is untrusted input: sanitize it and validate URLs before rendering or opening them.