When you build a chat UI that displays LLM output as it arrives, react markdown streaming partial chunks forces you to render incomplete syntax without crashing or mangling the DOM. Unlike a finished message, a streamed token sequence may cut off inside a code fence, a link, or an emphasis span, and a naive markdown parser will either throw or produce broken markup. The fix is a small pipeline that buffers text, tolerates unfinished constructs, and throttles re-parsing.
Step 1: Capture the stream without assuming line boundaries
Most LLM endpoints send token deltas as a byte stream (SSE or raw chunked HTTP). Do not rely on readline() or newline splits—tokens can arrive mid-line. Use the native ReadableStream reader and a TextDecoder to append raw text.
async function streamCompletion(
url: string,
body: unknown,
onChunk: (text: string) => void
) {
const res = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.body) throw new Error("no body");
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
onChunk(buffer); // full accumulated text so far
}
onChunk(buffer + decoder.decode()); // flush
}
The onChunk callback receives the entire concatenated string up to that point. That is the raw input you must render safely.
Step 2: Store raw text in state, but keep a ref for parsing
React state updates trigger renders; you want the latest raw text available without forcing a parse on every call. Keep a ref for the authoritative buffer and a state variable for the normalized markdown that actually gets rendered.
import { useRef, useState, useCallback } from "react";
function useStreamBuffer() {
const rawRef = useRef("");
const [renderText, setRenderText] = useState("");
const push = useCallback((full: string) => {
rawRef.current = full;
// parsing/throttling happens elsewhere
}, []);
return { rawRef, renderText, setRenderText };
}
This separation lets you throttle the expensive markdown step independently of network speed.
Step 3: Pick a parser that does not throw on partial input
CommonMark parsers are designed to be lenient. react-markdown (built on remark) will not throw on an unclosed * or a missing link target—it renders what it can. Avoid HTML passthrough unless you sanitize.
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
function Message({ text }: { text: string }) {
return (
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{text}
</ReactMarkdown>
);
}
For react markdown streaming partial chunks, this component will re-run on every text change. An open code fence like ```js simply renders as a code block with no closing tag yet—visually acceptable in a streaming UI.
Step 4: Normalize incomplete block constructs for preview
Inline partials are fine, but an unclosed fenced code block can swallow subsequent UI elements if you later inject metadata. Detect an odd number of fence markers and temporarily close the block for rendering only.
export function normalizePartialMarkdown(raw: string): string {
const fenceCount = (raw.match(/```/g) || []).length;
if (fenceCount % 2 === 1) {
// odd -> we are inside a code block, close it for preview
return raw + "\n```\n";
}
return raw;
}
Apply this inside your throttle step before calling setRenderText. The original rawRef stays untouched so the final completed stream renders correctly.
Handle incomplete inline links
A link like [title](https:// will be rendered as literal text by remark. No action needed, but if you use a custom renderer that intercepts href, guard against undefined:
ReactMarkdown({
components: {
a: ({ href, children }) => (
<a href={href ?? "#"} onClick={(e) => href || e.preventDefault()}>
{children}
</a>
),
},
})
Step 5: Throttle re-renders with requestAnimationFrame
Parsing markdown on every token (often 10–30 per second) wastes CPU and causes jank. Coalesce updates to one per animation frame.
import { useEffect, useRef } from "react";
function useThrottledRender(rawRef: React.MutableRefObject<string>, setText: (s: string) => void) {
const frame = useRef<number | null>(null);
useEffect(() => {
const tick = () => {
frame.current = requestAnimationFrame(tick);
setText(normalizePartialMarkdown(rawRef.current));
};
frame.current = requestAnimationFrame(tick);
return () => {
if (frame.current) cancelAnimationFrame(frame.current);
};
}, [rawRef, setText]);
}
Now react markdown streaming partial chunks only re-parses at display refresh rate, not per network chunk.
Step 6: Sanitize and guard against broken HTML
If you enable rehype-raw to support embedded HTML, you must sanitize. rehype-sanitize drops disallowed tags and attributes, preventing a partial <script> from executing.
import rehypeSanitize from "rehype-sanitize";
<ReactMarkdown rehypePlugins={[rehypeSanitize]}>{text}</ReactMarkdown>
Whether you consume a provider directly or route through an OpenAI-compatible gateway like n4n.ai, the delta format is just text chunks; the safety logic above is transport-agnostic.
Step 7: Verify the behavior with a simulated truncated stream
You need proof that the UI survives mid-stream cuts. Write a tiny test harness that feeds a known markdown string in random slices.
function sliceFeed(full: string, maxSlice: number, onChunk: (s: string) => void) {
let acc = "";
let i = 0;
const id = setInterval(() => {
const step = Math.floor(Math.random() * maxSlice) + 1;
acc += full.slice(i, i + step);
i += step;
onChunk(acc);
if (i >= full.length) {
onChunk(full);
clearInterval(id);
}
}, 30);
}
// Example partial-prone input
const sample = "# Title\n\nSome *italic and a [link](https://ex\n\n```js\nconst x = 1;\n";
sliceFeed(sample, 8, (t) => console.log(normalizePartialMarkdown(t)));
Success criteria
- No React error boundary triggers during the entire feed.
- An open code fence renders as a block, and after completion the block closes naturally.
- Inline partials (
*italic,[link](https://) show as plain text until closed; they never throw. - CPU usage stays flat: parsing occurs ≤60 times per second regardless of token rate.
Open Chrome DevTools, throttle network to “Slow 3G”, and watch the console. If you see Unexpected end of input from your markdown lib, you picked a non-tolerant parser—switch to remark-based tooling.
Step 8: Finalize on stream end
When the stream closes, stop normalization and render the raw string directly. This ensures any trailing fence or list item terminates per spec.
const { rawRef, setRenderText } = useStreamBuffer();
const [done, setDone] = useState(false);
// in streamCompletion onChunk:
push(buffer);
if (done) setRenderText(rawRef.current); // no normalize
// on done:
setDone(true);
setRenderText(rawRef.current);
That last assignment replaces the patched preview with the canonical parse, eliminating the synthetic closing fences you added for safety.
Takeaways for production
- Treat the stream as untrusted, partially valid markdown; never
dangerouslySetInnerHTMLwithout sanitization. - Keep the buffer in a ref; derive rendered state on a rAF cadence.
- Normalize only for preview, not for storage.
- Test with random slicing—real LLM tokenization will cut in worse places than your intuition expects.
Following these steps, react markdown streaming partial chunks becomes a solved problem: the UI stays responsive, the layout never collapses, and the final message is byte-identical to a non-streamed render.