How do you render streaming markdown from an LLM safely and without flicker?
Advertisement
🧩 Scenario
Concept Walkthrough
Safety first: model output is untrusted
Treat LLM output exactly like text a user typed into a form. A model can be pushed by prompt injection (through a web page, a document or a user message) into emitting HTML or links you did not intend. If that output ever reaches the DOM as raw HTML, you have an XSS problem.
The common unsafe pattern is: parse markdown to an HTML string with a parser, then render it with dangerouslySetInnerHTML. Without sanitizing, any <img onerror> or <script>-like payload goes straight in.
The safer pattern is to turn markdown into React elements. react-markdown builds a syntax tree and renders components, and its readme says it does not use dangerouslySetInnerHTML. Raw HTML in the markdown is escaped by default (or dropped with skipHtml), and URLs pass through defaultUrlTransform, which makes them safe. You lose that safety if you add rehype-raw for untrusted content or write a loose urlTransform. If you must allow some HTML, add rehype-sanitize with an allow-list.
Why streaming markdown flickers
Markdown is designed for complete documents. Mid-stream, the text is almost always half-finished:
- An opening code fence without a closing fence can turn everything after it into a code block until the fence closes.
**importantshows literal asterisks until the closing**arrives, then jumps to bold.- A link shows as bracket text until the URL is complete.
Each of these causes a layout jump when the closing token arrives.
Why it gets slow
If you pass the full answer to one markdown component, every chunk re-parses and re-renders the whole thing, including code highlighting. Only the last paragraph changed.
The fix: block-level memoization
Split the markdown into top-level blocks (paragraphs, lists, code blocks) and render each block in its own memoized component keyed by message id and block index. Finished blocks keep the same string, so React.memo skips them, and only the last block re-parses. The AI SDK v4 cookbook used marked.lexer for this split.
For incomplete syntax you can either accept small jumps, add your own "close open fences" step, or use Streamdown. Its docs describe it as a drop-in replacement for react-markdown built for AI streaming that handles unclosed bold, code blocks and links. In the current AI SDK recipe, Streamdown gets an isAnimating prop so it can disable interactive controls until the text is complete.
Key Code Explained
import { marked } from 'marked';
import { memo, useMemo } from 'react';
import Markdown from 'react-markdown';
// Split the full markdown into top-level blocks (raw source strings)
function parseMarkdownIntoBlocks(markdown: string): string[] {
return marked.lexer(markdown).map((token) => token.raw);
}
// One block = one memoized render. Same content string means no re-render.
const MarkdownBlock = memo(
function MarkdownBlock({ content }: { content: string }) {
// react-markdown renders React elements, escapes raw HTML by default,
// and runs URLs through its safe default urlTransform
return <Markdown>{content}</Markdown>;
},
(prev, next) => prev.content === next.content,
);
export function MemoizedMarkdown({ id, content }: { id: string; content: string }) {
const blocks = useMemo(() => parseMarkdownIntoBlocks(content), [content]);
return (
<>
{blocks.map((block, index) => (
<MarkdownBlock key={`${id}-block-${index}`} content={block} />
))}
</>
);
}
marked is used only to find block boundaries; its HTML output is never rendered. Rendering stays inside react-markdown, so you keep its default escaping. Because blocks are keyed by index and earlier blocks stop changing once complete, only the block that is still growing re-renders on each update.
Tradeoffs
| Approach | Safety | Streaming behaviour | Cost |
|---|---|---|---|
| Parser HTML + dangerouslySetInnerHTML | Unsafe unless sanitized | Re-renders everything | Easy to write, easy to get wrong |
| react-markdown on full text | Safe by default | Re-parses whole answer per update | Slows down on long answers |
| Block-memoized react-markdown | Safe by default | Only last block re-parses | Small extra parsing step |
| Streamdown | Check its security options in the docs | Handles unclosed syntax, memoizes blocks | Extra dependency with Tailwind setup |
What Interviewers Actually Check
- Whether you treat model output as untrusted input
- Whether you avoid
dangerouslySetInnerHTMLor sanitize when you cannot - Whether you can explain flicker in terms of incomplete syntax
- Whether you limit re-parsing to the part that changed
- Whether you think about interactive bits (copy buttons, links) during streaming
Follow-Up Questions
- How would you handle syntax highlighting so it does not run on every chunk?
- What would you put in a
rehype-sanitizeallow-list if product wants tables and images? - How would you stop the model from linking users to arbitrary domains?
- How would you render math or diagrams in streamed output?
- How would you test that a malicious markdown payload renders as plain text?
Common Candidate Mistakes
- Saying "we sanitize on the server" while still rendering client HTML with
dangerouslySetInnerHTML - Not knowing that adding
rehype-rawchanges the security picture - Keying blocks by random ids, which remounts every block on every update
- Highlighting code blocks while their fence is still open, then highlighting again on close
- Forgetting that links and images in model output can point anywhere
Interview Readiness Checklist
Before you leave this question, make sure you can answer:
- Can you explain why LLM output is an XSS risk?
- Can you describe what react-markdown does with HTML and URLs by default?
- Can you write a block-memoized markdown component?
- Can you explain why unclosed fences cause flicker?
- Can you explain what a streaming-aware renderer like Streamdown adds?
Summary
Streaming markdown has a safety problem and a rendering problem. For safety, treat model output as untrusted and render markdown to React elements with a library such as react-markdown, which escapes raw HTML and sanitizes URLs by default, rather than injecting parsed HTML. For rendering, split the text into blocks, memoize finished blocks so only the growing block re-parses, and handle unclosed syntax so the layout does not jump. Streamdown packages these ideas for AI chat UIs, and you can disable interactive controls such as copy buttons until a block is complete.
Is react-markdown safe by default?
Its readme describes it as secure by default: it does not use dangerouslySetInnerHTML, raw HTML in the markdown is escaped, and URLs go through a safe default urlTransform. Safety can be lost through your own choices, such as adding rehype-raw for untrusted content, unsafe plugins, or a custom urlTransform.
Why does the answer flicker while streaming?
The text is incomplete most of the time. An opening ``` without its closing fence, or ** without its closing pair, is parsed differently from the finished version, so the layout jumps when the closing token arrives.
What is Streamdown?
An open source React component from Vercel that its docs call a drop-in replacement for react-markdown, designed for AI streaming. It handles incomplete syntax such as unclosed bold, code blocks and links, and the AI SDK recipe notes it memoizes blocks so unchanged ones skip re-rendering.
Advertisement
Stay Updated
Get the latest frontend challenges, interview questions and tutorials delivered to your inbox.
Advertisement