react-markdown streaming performance: every token re-parses the whole chat transcript, measured
Each streamed token re-parses every earlier answer in react-markdown. Measured in Chromium: 575 ms by the 11th answer, 50 ms with React.memo.
The kit's demo chat streams each answer token by token and renders it with react-markdown, and react-markdown streaming performance gets worse with every answer in the conversation. Every text delta replaces the messages array, every assistant bubble in the transcript re-renders, and react-markdown re-parses each one from scratch. On the eleventh answer of a conversation, the shipped code spent about 575 ms inside react-markdown while that one answer streamed. With finished answers memoized it was about 50 ms. Here is where that work comes from, how I measured it, and the one-line fix.
Why every token re-parses the whole transcript
Each text frame from the stream becomes a state update that copies the array and replaces the last message:
setMessages((current) => {
const next = [...current];
const last = next[next.length - 1];
next[next.length - 1] = { ...last, content: last.content + parsed.text };
return next;
});
The earlier messages keep their object identity, so the data says nothing changed for them. The render does not look at that. ChatPage maps every message to <AssistantMarkdown content={message.content} />, and AssistantMarkdown is a plain function component, so React calls it again for every answer on every update. Inside it, react-markdown 10.1.0's default export runs the whole pipeline on each call, with no cache:
export function Markdown(options) {
const processor = createProcessor(options)
const file = createFile(options)
return post(processor.runSync(processor.parse(file), file), options)
}
That is a fresh parse to a markdown tree, a transform to an HTML tree and a conversion to React elements, for text that has not changed since it finished streaming. The kit does not enable the React Compiler (its next.config.ts sets no options), so nothing memoizes the component implicitly either.
Measured: react-markdown streaming performance with and without memo
I ran the kit's production build (next start) in Chromium through Playwright, with /api/chat answered by a local mock that streams the kit's own SSE events. Each answer is the same 1,017-character markdown reply (a heading, a numbered list, a TypeScript code block, bullets and a link), sent as 85 deltas of 12 characters, 20 ms apart. I asked 11 questions in a row. For answers 2, 6 and 11 I recorded self time inside the 114 KB react-markdown chunk from a CPU profile, total script time from Chrome's performance metrics, and DOM updates from a MutationObserver.
Unthrottled, on the shipped code, across three profile runs:
- Answer 2: 119–121 ms in react-markdown, 188–192 ms of script.
- Answer 6: 333–455 ms in react-markdown, 462–536 ms of script.
- Answer 11: 564–589 ms in react-markdown, 775–784 ms of script.
With AssistantMarkdown wrapped in React.memo, one run gave 48, 47 and 52 ms in react-markdown and 106, 108 and 126 ms of script. The cost stops depending on the length of the conversation, because only the bubble that is streaming renders. Unthrottled, every answer got 88 DOM updates: one per delta, plus a few for the input and the usage panel.
The throttled run is where users notice it. With Chrome's CPU throttling at 4x, roughly a mid-range phone:
- Shipped code: 782–860 ms of script on answer 2 and 1,009–1,048 ms on answer 11, with 4 long tasks on answer 11 in one run (the longest 102 ms). DOM updates fell from 79 on answer 2 to 38 on answer 6 and 23 on answer 11.
- Memoized: 502–573 ms of script on every answer, no long tasks, and 82–86 DOM updates per answer.
The update count is the part a user sees. While the main thread is busy re-parsing old answers, network chunks pile up, reader.read() returns several frames at once, and React batches them into one render. Nothing freezes. Instead, by the eleventh answer, 85 deltas arrive as 23 visible jumps and the stream looks choppy. The state handling for streaming responses counts on that batching to stay correct, and suggested throttling renders only if a long transcript measured slow. This is that measurement, and the fix turns out to be smaller than throttling.
The fix: memoize the bubble
import { memo, useRef, useState } from "react";
const AssistantMarkdown = memo(function AssistantMarkdown({ content }: { content: string }) {
return (
<div className="space-y-2 leading-relaxed">
<ReactMarkdown components={{ /* unchanged */ }}>{content}</ReactMarkdown>
</div>
);
});
memo compares props shallowly, and the only prop is a string. A finished answer's string is identical on every update, so React skips it. The streaming answer's string changed, so it renders. The kit's chat-client.tsx ships without the memo today. This is the change I would make, and the memoized numbers above come from exactly this edit, built and run the same way.
Things that bite
- A new prop object defeats it. The kit builds the
componentsoverrides insideAssistantMarkdown, so the memo only ever seescontent. Passcomponents, or anything else built inline, down from the parent andmemogets a new object on every render and re-renders anyway. Hoist it to module scope. - The streaming answer still costs more as it grows. Memo removes the old answers, not the one being written: each delta re-parses that whole answer so far. Streaming a single answer, unthrottled, cost 84–89 ms in react-markdown at 1,017 characters, 188–192 ms at 2,036 and 559–562 ms at 4,074. Four times the text cost about six times the parsing. For very long replies, render completed blocks once and re-parse only the last one, splitting on blank lines outside code fences, or show the in-progress tail as plain text.
- A fast laptop hides it. Unthrottled, the shipped code produced no long tasks in my runs, even at answer 11. Profile with CPU throttling, and count renders as well as milliseconds.
- Pages that do not stream do not need this in the browser. The blog renders finished text, so its markdown can be parsed on the server instead; that tradeoff is in react-markdown as a server component.
Shipwright, the Next.js and Claude starter kit this blog documents, streams Claude's answers into a chat UI with markdown rendering and per-request cost metering already wired up. You can watch the stream and the cost panel in the live demo.