Topic 6.1
Markdown While It Streams
In one line
Models write Markdown, and a stream delivers it a few characters at a time, so every update shows half a bold mark, half a link or half a code fence. You fix it by closing unfinished marks before drawing, and by drawing React elements instead of HTML strings.
Think of it like this
Someone writes a sentence in brackets on a whiteboard while you watch. They write the opening bracket first. Until the closing bracket appears, the sentence looks wrong, and you have to guess what is coming. **Markdown is full of brackets like this: **bold**, ` code , [label](url)`, and code fences. A stream shows you every moment between the opening and the closing.**
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- Markdown
- A way to mark up plain text with simple symbols:
**bold**,- item,# heading, and so on. - Block
- A whole piece of a document: a paragraph, a heading, a list or a code block.
- Inline mark
- Formatting inside a block: bold, italic, inline code, a link.
- Flicker
- Text that changes shape between updates, for example stars that appear and then turn into bold.
- Heal
- Close whatever the model has not closed yet, before parsing, so a half-written mark draws as if it were finished.
- Code fence
- Three backticks on a line of their own. Everything up to the next fence is code.
- Open fence
- A fence whose closing line has not arrived yet.
- React element
- A description of something to draw, such as
<strong>text</strong>. React turns elements into page content without parsing HTML text. - Parser
- Code that reads text and turns it into a structure (here: blocks and marks).
Step by step
01What the model actually sends
Open any chat model and ask a question. The answer contains **bold**, lists that start with - , headings, links and code in fences, even if you never asked. Models write Markdown by habit, because their training text was full of it.
So the string you receive is not a finished page. It is Markdown source, and you are the renderer.
02Render every prefix and look
The first live example takes one answer, cuts it at awkward places (right after **, inside a link) and renders each cut with the plain renderer. Read the -> column: Order **ORD-1042* even turns into an italic word for one frame, because *ORD-1042* looks like italics.
Every row is a screen a user could see. All but the last are wrong.
03Heal before you parse
heal(text) looks only at the last paragraph, where the unfinished marks must be. It drops a half link ([here](https://sh becomes here), removes a marker with nothing after it (**), and closes an open ** or backtick.
The second live example prints plain and healed side by side for seven cut points, including an open code fence, a list, and Hindi text with an emoji.
04The real thing, with the AI SDK
The third example is a real chat: useChat on the client, streamText on the (stand-in) server. The assistant's text part is healed and drawn with <Markdown> on every update. The page records the text of every screen it showed, and at the end reports whether any screen showed stray marks.
With heal on, no screen did. Turn it off (try it below) and the first one is Your order **.
05Real libraries
In an app you will usually not write the parser. react-markdown (10.1.0) turns Markdown text into React elements and is safe by default, but it re-parses the whole string on each render and does not heal. Streamdown (2.7.0, from Vercel) is built for streaming and uses remend (1.4.0) to heal unfinished text. Both appear below as non-live blocks, because the preview does not include them.
You can also use remend on its own: import remend from "remend" and call remend(text) before handing the text to the parser of your choice.
06What to test
Test the renderer with prefixes of real answers: every cut point, not only the final text. A test that loops over all prefixes of a long answer and checks "no stray marks" catches most streaming bugs in one go. The practice questions below ask you to write one.
Try it yourself
- 1
Find your own bad cut
In the first example, change
fullto"Order **ORD-1042** has shipped. Run \x60track 1042\x60."(the\x60is a backtick) and add the cutat("Run \x60tr")to the list.Read the new row. The plain renderer shows the backtick as an ordinary character until the second one arrives.
- 2
Turn healing off
In the third example, change
const HEAL = true;tofalseand run it.The page reports
any screen showed stray marks: yes, andfirst oneshows the earliest bad screen. - 3
Cut an emoji in half
In any example, log
JSON.stringify("Chai ☕ 👍!".slice(0, 8)). It prints a lone surrogate ("Chai ☕ \ud83d") because the thumbs-up takes two code units andslicestopped in the middle.Then log
Array.from("Chai ☕ 👍!").slice(0, 8).join(""). It prints"Chai ☕ 👍": it cuts between whole characters, so the thumbs-up arrives whole.
Code & diagrams
Nothing is drawn on the page; the console shows the HTML the browser would hold for each cut. Look at the third row: for one frame the bold word is italic.
Preview
Console
Nothing logged yet.Expected console output
"Order *" -> <p>Order *</p>
"Order **ORD" -> <p>Order **ORD</p>
"er **ORD-1042*" -> <p>Order *<em>ORD-1042</em></p>
"r **ORD-1042**" -> <p>Order <strong>ORD-1042</strong></p>
"Track it [here" -> <p>Order <strong>ORD-1042</strong> has shipped. Track it [here</p>
"tps://shop.exa" -> <p>Order <strong>ORD-1042</strong> has shipped. Track it [here](https://shop.exa</p>
"e/track/1042)." -> <p>Order <strong>ORD-1042</strong> has shipped. Track it <a href="https://shop.example/track/1042" rel="noopener noreferrer" target="_blank">here</a>.</p>Two cases need no healing: the list (a list item can be any length) and the open code fence (the parser already draws an open fence as code). Healing is only for marks that wrap text.
Preview
Console
Nothing logged yet.Expected console output
"Order **ORD-10"
plain : <p>Order **ORD-10</p>
healed: <p>Order <strong>ORD-10</strong></p>
"Order **"
plain : <p>Order **</p>
healed: <p>Order </p>
"Run `npm run"
plain : <p>Run `npm run</p>
healed: <p>Run <code>npm run</code></p>
"](https://shop.exa"
plain : <p>Track it [here](https://shop.exa</p>
healed: <p>Track it here</p>
"Steps:\n\n- one\n- tw"
plain : <p>Steps:</p><ul><li>one</li><li>tw</li></ul>
healed: <p>Steps:</p><ul><li>one</li><li>tw</li></ul>
"Mitra में **चाय ☕"
plain : <p>Mitra में **चाय ☕</p>
healed: <p>Mitra में <strong>चाय ☕</strong></p>
"```js\nconst a = 1;"
plain : <p>Code:</p><pre data-lang="js"><code>const a = 1;</code></pre>
healed: <p>Code:</p><pre data-lang="js"><code>const a = 1;</code></pre>The scripted visitor presses the button once. Set HEAL to false and run again to see the other result.
Preview
Console
Nothing logged yet.Expected console output
any screen showed stray marks: no
first one: null
final: <p>Your order <strong>ORD-1042</strong> has shipped. Track it <a href="https://shop.example/track/1042" rel="noopener noreferrer" target="_blank">here</a> or run <code>track 1042</code>.</p><ul><li>Arrives in 2 days</li><li>Free returns for 7 days</li></ul>react-markdown 10.1.0, streamdown 2.7.0, @streamdown/code 2.0.0 and remend 1.4.0 are the versions npm lists today. The preview does not bundle them, so this block is read-only.
// react-markdown: safe by default, parses the whole string on every render, does not heal
import Markdown from "react-markdown";
export function Answer({ text }: { text: string }) {
return <Markdown>{text}</Markdown>;
}
// Streamdown: built for streaming. It heals unfinished Markdown (using remend) and can animate new text.
import { Streamdown } from "streamdown";
import "streamdown/styles.css";
import { code } from "@streamdown/code"; // optional plugin: syntax-highlighted code blocks (Topic 6.2)
export function StreamingAnswer({ text, streaming }: { text: string; streaming: boolean }) {
return (
<Streamdown plugins={{ code }} isAnimating={streaming}>
{text}
</Streamdown>
);
}
// remend on its own: close unfinished marks, then use any parser you like
import remend from "remend";
const healed = remend("This is **bold text"); // "This is **bold text**"Break it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
Render the raw text
In the third example, change const HEAL = true; to const HEAL = false; and run it.
Myth vs fact
Myth
Markdown rendering is solved: I can drop the answer into any renderer.
Fact
Renderers are written for finished documents. A stream gives them unfinished text at every update, which is why streaming-aware tools exist.
Myth
Flicker is only cosmetic.
Fact
A stray ** or half a URL makes an answer look broken. A half code fence can swallow the rest of the message as plain code if the renderer gives up. Users judge reliability by how the text looks while it arrives.
Myth
The final text is what I should test.
Fact
Test every prefix. The final text is the one moment everything is balanced.
Myth
I should convert Markdown to an HTML string and set it with innerHTML.
Fact
That is the unsafe route (Topic 6.3). Produce React elements, as the renderer here does.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
Healing is a heuristic. It looks at the last paragraph and guesses what the model meant. It can be wrong: a lone
*used as a bullet, or2 * 3 * 4in a sum, can look like italics. Keep the rules few and test them against real answers. - ▸
Re-parsing the whole text on every token costs more as the answer grows. For a short chat answer it does not matter; for a long one it does. Topic 6.5 shows how to parse only the unfinished block.
- ▸
React keys matter while streaming. If block 3 is a paragraph now and becomes a code block a moment later, a key made from the block's index keeps React reusing the same slot. Keys made from the content would remount the element on every character.
- ▸
Streamed output often arrives in a different shape from what you tested: the SDK delivers whatever the provider chose. Never assume a chunk ends at a word, a space, or a mark.
Remember this
- 1
The problem this solves: A chat model writes its answer in Markdown, a plain-text way to mark up text (
**bold**,- list item,# heading). If you show that text as it is, users see stars and hashes. If you run it through a Markdown renderer once at the end, the answer appears all at once, which throws away the point of streaming. So you must render Markdown *while it arrives*, and the text you render is incomplete at almost every moment. - 2
What "incomplete" looks like. Say the answer is
Order **ORD-1042** has shipped.AfterOrder **the renderer sees two stars with nothing closed. A normal Markdown renderer prints them literally. A few characters later the closing**arrives and the stars vanish and the word turns bold. Users see text that jumps. This is called flicker, and it also happens with ``, with*italic*, with-list markers and with[label](https://...links, where the half URL is shown as text until the)` arrives. - 3
Code fences are the big one. A fenced code block starts with three backticks and ends with three backticks. Until the closing line arrives, every line after the opening one belongs to the code. A renderer must treat an unclosed fence as an *open* code block and keep drawing it as code, not give up and print backticks as text (Topic 6.2 covers code blocks fully).
- 4
Four ways to cope. (1) Wait for the whole answer and render once: no flicker, no streaming. (2) Render the full text again on every update with a normal parser: simple, but flickers. (3) Heal the text first: before parsing, close whatever is open (add the missing
**, drop a half URL), then parse. (4) Render only finished blocks as Markdown and show the last, unfinished block as plain text. Option 3 is what libraries such as Streamdown do, with a package calledremend. - 5
Draw elements, not HTML strings. The renderer in this topic returns React elements (
<strong>,<a>,<p>). It never builds an HTML string and never callsinnerHTML. That is partly about speed and partly about safety: text a model wrote can contain anything, and React puts text into the page as *text*. Topic 6.3 explains why this matters. - 6
Real parsers follow rules you did not write. The Markdown standard (CommonMark) has detailed rules for when a
*opens bold, and those rules are about the characters around it. The small renderer here is deliberately simpler: no nesting, a few marks. Real libraries are far more complete. Know what they do for you, so you can pick one and know where it can surprise you, for example around East Asian text, which Streamdown handles with an optional plugin. - 7
Edge cases that need a decision. *Emoji and non-Latin text:* a chunk from the SDK is always a complete string, but code you write that cuts text by position (
slice) can cut an emoji in half and print a broken character. Cut withArray.from, never with a raw index. *An empty answer:* render nothing, not an empty box. *One token:* the same code path as a thousand; test it. *A huge answer:* see Topic 6.5.
Explain it without notes
Why does a streamed Markdown answer flicker, and how do you stop it?
What should a renderer do with a code fence that has no closing line yet?
Why render React elements instead of building an HTML string?
Name four strategies for rendering streaming Markdown, with the cost of each.
Practice
Write a test that renders every prefix of a long answer with heal and fails if any screen's text contains **, ]( or a backtick.
Extend heal so an open *italic is closed too, but a * followed by a space (a bullet or a multiplication sign) is left alone.
Trade-offs
- ↔
A hand-written renderer teaches every rule and has no dependency, but it will never match the full Markdown standard (tables, nested lists, footnotes). In a real product use a library, and write your own only for a deliberately small subset.
- ↔
Healing makes the stream look smooth by guessing. The alternative, showing the last block as plain text, never guesses but shows raw marks for a moment. Pick by how much your users care about the look of the last line.
Done when you can
I can name what goes wrong when Markdown is rendered with each new chunk.
I can heal an unfinished bold, inline code or link before parsing.
I can explain how an open code fence is drawn.
I can render Markdown to React elements without
innerHTML.I can test a streaming renderer with every prefix of an answer.