Search IOCombats

Search challenges, guides, questions and articles

How do you render streaming markdown from an LLM safely and without flicker?

Intermediate12 min interview
Skills tested:
Explaining why model output is untrusted inputKnowing how react-markdown handles HTML and URLs by defaultSplitting markdown into blocks and memoizing finished onesExplaining where flicker comes from with unclosed syntaxDeferring interactive pieces like copy buttons until a block is complete

Advertisement

🧩 Scenario

Your AI assistant answers in markdown with headings, lists and code blocks. Users report two problems: long answers make the page stutter, and code blocks flash between plain text and formatted code while streaming. A security reviewer also asks why the markdown renderer is allowed to output HTML. The interviewer asks how you would design the renderer.

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.
  • **important shows 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

ApproachSafetyStreaming behaviourCost
Parser HTML + dangerouslySetInnerHTMLUnsafe unless sanitizedRe-renders everythingEasy to write, easy to get wrong
react-markdown on full textSafe by defaultRe-parses whole answer per updateSlows down on long answers
Block-memoized react-markdownSafe by defaultOnly last block re-parsesSmall extra parsing step
StreamdownCheck its security options in the docsHandles unclosed syntax, memoizes blocksExtra dependency with Tailwind setup

What Interviewers Actually Check

  • Whether you treat model output as untrusted input
  • Whether you avoid dangerouslySetInnerHTML or 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

  1. How would you handle syntax highlighting so it does not run on every chunk?
  2. What would you put in a rehype-sanitize allow-list if product wants tables and images?
  3. How would you stop the model from linking users to arbitrary domains?
  4. How would you render math or diagrams in streamed output?
  5. 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-raw changes 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.

Frequently Asked Questions

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.

Namaste Dev
Sponsored

Cohort-style courses to go from tutorials to job-ready.

Explore Courses

Advertisement