Markdown that is still arriving
An unclosed code fence, a half-written table and a dangling link are the normal state of a streaming markdown buffer — parsing it on every chunk with a parser that assumes a complete document is how the answer area flickers.
The previous lesson pulled the structured parts of an answer out into a schema. What is left is the rationale string, and on this surface that string carries the most valuable thing in the response: a comparison table of two award routes, and a fenced block showing the transfer sequence in order. Both are markdown. Both are broken for most of the time they are on screen.
Here is one buffer, sampled four times on its way in:
t=1 Compare the two routes:
t=2 Compare the two routes:
| Route | Points |
t=3 Compare the two routes:
| Route | Points |
| --- | --- |
| ANA via NRT | 60,00
t=4 Compare the two routes:
| Route | Points |
| --- | --- |
| ANA via NRT | 60,000 |
| VS via LHR | 47,500 |At t=2 that is not a table. It is a paragraph containing pipe characters, and any parser that follows the spec will tell you so, correctly. At t=3 it becomes a table with one row. Feed each of those to a parser that assumes a complete document and you get three different trees in three chunks: a paragraph, then a table, then a taller table. The DOM is rebuilt into a different shape each time. That is the flicker, and its cause is structural, not computational.
What actually breaks
Vercel built a streaming markdown renderer, Streamdown, specifically for this — and being a vendor that also sells the ecosystem it belongs to, its README doubles as the best available inventory of the problem:
“Formatting Markdown is easy, but when you tokenize and stream it, new challenges arise… providing seamless formatting even with incomplete or unterminated Markdown blocks.”
The named cases are “bold text that hasn’t been closed yet, partial code blocks missing their closing backticks, and unterminated links without closing brackets”, plus progressive tables, where a row can arrive before its header. Read that list next to the previous lesson’s test and the overlap is exact. An unterminated link is a prefix that asserts a different claim: [Book on ANA](https:// is a link to nowhere that looks like a link to somewhere, and it is clickable the moment a parser decides it is a link.
The repair engine has a name and not much more. The README credits unterminated-block parsing to remend. What remend does is documented as “detects and closes unterminated syntax” and no further. Do not build a mental model of its algorithm from the name; the public documentation stops there, and a repair strategy you cannot inspect is one you cannot predict on your own content.
The library does not make this go away
Worth stating before you reach for it. In the same repository, an open issue reports that fenced code blocks do not render incrementally during streaming. That is not a knock on the project. It is the useful fact: the vendor that built the dedicated tool for this problem, and sells the platform around it, has the problem in its own tracker. Nothing available makes streamed markdown flicker-free in general, so the decision below is yours regardless of what you install.
Streamdown does expose the state you need to make it. The hook useIsCodeFenceIncomplete returns true when streaming mode is active, the component is in the last block being streamed, and that block has an unclosed code fence— which is precisely the predicate this lesson has been circling.
import { Streamdown, useIsCodeFenceIncomplete } from 'streamdown'
function Answer({ markdown, isStreaming }) {
return <Streamdown isAnimating={isStreaming}>{markdown}</Streamdown>
}
// Inside a custom code-block renderer registered with Streamdown:
function CodeBlock({ children }) {
const isIncomplete = useIsCodeFenceIncomplete()
if (isIncomplete) return <div className="animate-pulse h-24 rounded" />
return <pre><code>{children}</code></pre>
}Note what that example actually chooses: while the fence is open, it renders nothing — a pulsing rectangle where the code will go. That is the hold decision from the disclosure table, applied to a block type. It is one of three defensible answers, and the library picking it in its own docs does not make it the right one for your content.
Three treatments, per block type
The decision is the same shape as the per-field decision in the last lesson, applied per markdown block rather than per schema field.
| Treatment | What the user sees mid-block | Costs you |
|---|---|---|
| Render raw | The literal characters, unstyled, until the block closes and it reflows | One visible reflow per block, and pipes on screen |
| Hold | A placeholder in the block’s eventual position | The content is invisible while it arrives, so a long block reads as a stall |
| Repair and render | A styled block, closed by the parser, growing in place | A guess about intent, and rows or emphasis that can change meaning as they complete |
Now assign them, and the assignment falls out of the previous lesson’s test rather than from taste. Prose and emphasis: repair, because an unclosed bold is a formatting error with no semantic content and a reader who sees an asterisk has lost nothing. Code fences: hold or render raw, because a partial command is a runnable command and the whole point of a fenced block is that it is copied. Tables: hold until the header and separator have both arrived, then repair, because a one-row table is a complete claim about a comparison that has one option in it. Links: hold the anchor until the closing parenthesis lands; a half-built href is a navigation to somewhere you did not choose.
Where people get burned
The trap here is a fix that works and moves the problem. Parsing only up to the last blank line — the last complete block boundary — genuinely eliminates flicker, because everything you render is finished markdown. Then you watch it with a model that writes a nine-line fenced block and the answer area sits frozen for the entire block, which the user reads as a hang, in the middle of an interface whose entire justification was continuous proof of life.
The rule that survives both failure modes: the boundary between “parsed and styled” and “raw and provisional” must be visible. If the user can see where the finished part ends and the arriving part begins, a reflow at that seam is expected rather than startling. If they cannot, every reflow is the page changing its mind.
Check your recall
Answer from memory — no scrolling back.
Retrieval check
A teammate proposes “just render everything raw until the stream ends, then parse once.” It flickers zero times. Name what it costs.
Check your answer
It costs the reading. The user watches pipes, asterisks and backticks accumulate for forty seconds and then the answer transforms into a document. Every intermediate state is legible-ish and none of it is the product. It also converts the final parse into a single large layout change at the exact moment the user has settled into reading the raw version.
More usefully, it gives up the property this module exists to protect: the ability to judge direction early. Two rows into a comparison table you can tell the agent priced the wrong cabin and stop it. Two hundred characters into a pipe-delimited soup you cannot. Zero flicker is not the goal — a visible, expected seam between finished and arriving content is.
Hands on
Give each markdown block type a streaming treatment
Done when: The chatbot’s answer area renders arriving markdown with a per-block-type treatment you chose in writing, no unterminated link is clickable, no partial fenced block is copyable, and the boundary between finished and arriving content is visible on screen.
- Capture a real buffer. Log the
rationalestring on every chunk for one answer containing a table and a fenced block, and save the log. You are going to replay it, because reproducing this by hand against a live model is miserable. - Replay the log into your current renderer at a slow, fixed rate. Record every distinct broken state you can see. Expect at least one you had not thought of — a heading that was briefly a paragraph, an ordered list that renumbered.
- Write the treatment table: every block type your model actually emits, one of render-raw, hold, or repair, and the reason. Blocks the model never emits do not get a row; this is an inventory of your content, not of the markdown spec.
- Implement it. Whether you adopt Streamdown or buffer to the last complete block yourself is your call — the pass condition is behavioural, not architectural. Two hard requirements: an unterminated link must not be clickable, and an open fenced block must not be copyable.
- Make the seam visible. The user must be able to tell, without being told, where the finished answer ends and the arriving edge begins. Then replay the log again and confirm every reflow happens at that seam and not above it.
- Record the treatment table and the replay log path in
ARTIFACT.md, and bring the table into the chat. I will push on any block type marked repair whose partial form can mean something different from its complete form.
What this does not cover
Every hold in the treatment table leaves a rectangle of nothing, and this lesson has been quietly promising that a placeholder goes there without saying what a placeholder may honestly claim. A pulsing block the height of a code fence asserts that a code fence is coming and roughly how tall it will be, and you know neither. That is the honest-skeleton lesson, which closes this module.
Nothing here addresses what re-parsing and re-rendering the buffer costs. There is no number in this lesson on purpose: parse cost per token, re-render cost per chunk, memoization boundaries and whether any of it fits an interaction budget belong to Front-end performance under streaming load, which owns measurement and profiling in full. The flicker described here is caused by structural invalidity and would happen on an infinitely fast machine.
And the whole module assumes the stream keeps arriving. What the answer area should do when the user stops it mid-table, or when the connection dies at token 400 leaving an open fence on screen forever, is the control module, starting with the stop-button lesson.
Read this next — primary source
StreamdownVercel — vendor building, documenting and shipping this library as part of the AI SDK ecosystem it sells. Free, MIT. Fetched 2026-09-05
Read the README as a specification of the problem rather than as a recommendation of the solution. It is the clearest published enumeration of what breaks when markdown is parsed before it is finished — unterminated emphasis, fences missing their closing backticks, links with no closing bracket, a table row that arrives before its header — and each of those is a decision you owe an answer to whether or not you adopt the library. Then open the issue tracker, which is the honest other half: the same repository shows incremental code-fence rendering as an open problem.
Stuck, curious, or think this lesson is wrong? Ask your teaching agent. The lessons are the scaffold; the conversation is where the learning gets unstuck.