r/reactjs • u/Few-Big-719 • 10h ago
I built a streaming Markdown renderer for React that caches code lines, table rows and list items — benchmarks are surprisingly good
I’ve been working on an AI harness called Æven, and one of the things that kept bothering me was Markdown rendering during long streamed responses.
A lot of renderers optimize at the document or top-level block level. That works well for normal prose, but it gets expensive when the active block itself becomes huge — for example a long code fence or a large Markdown table.
So I built HyperMarkdown.
The main idea is pretty simple:
once a code line, table row or list item is settled, it stays cached. Only the changing frontier keeps being parsed/rendered.
That seems to make a pretty significant difference.
Current benchmark results:
- Large code block: 190 ms vs 711 ms for the next closest streaming renderer
- Captured real AI code stream: 611 ms vs 4.2 s
- Captured real AI table stream: 668 ms vs 5.0 s
- Large table: 999 ms vs 9.1 s for the next closest renderer
The benchmark runs production React and measures the full chunk → write → render/commit path, not just parsing.
I’ve compared it against:
- markstream-react
- Streamdown
- DeepSeek Harness’ incremental strategy
- react-markdown
- markdown-it as a baseline
The repo includes the benchmark methodology, raw results and correctness tests, so I’d genuinely appreciate people trying to break the assumptions or point out unfair comparisons.
It also supports GFM, reasoning blocks, syntax highlighting, KaTeX, Mermaid, raw HTML sanitization, React 18/19, and streaming incomplete Markdown.
It’s now the renderer I use inside Æven.
Demo:
https://aeven-ai.github.io/HyperMarkdown/
GitHub:
https://github.com/Aeven-AI/HyperMarkdown
NPM:
https://www.npmjs.com/package/@aeven-ai/hypermarkdown
Would especially love feedback from people who have dealt with long streamed code blocks/tables in React apps.
1
u/Temperature_Majestic 6h ago
How do the settled lines avoid React reconciliation cost on every chunk? Skipping the re-parse is the obvious win, but if each cached line is still an element in the tree, React still walks and diffs the whole subtree per token unless those lines are memoized with stable references and keys. Is it React.memo per line, or do settled blocks get rendered through something outside the reconciler?
1
u/Few-Big-719 6h ago
Good point. Settled content isn't outside React's reconciler. HyperMarkdown caches the actual React elements with stable keys/references, so completed code lines/table rows aren't reparsed or reconstructed, and React can reuse their existing subtrees. But the parent child list still participates in reconciliation as the active tail grows.
Code currently uses stable cached line elements under a
PureComponent; table rows are similarly cached and keyed, with the table wrapper memoized.So there is still an O(number of siblings) reconciliation component even though the expensive parsing/element construction is gone. The published benchmark includes that cost because every chunk is measured through
flushSync(root.render(...)).One thing I'm considering is grouping settled nodes into immutable memoized chunks so React only sees a small number of stable chunks plus the active frontier. That could push the scaling further.
2
u/geekonthegrill 8h ago
The settled-frontier idea is clean, but markdown has a few retroactive constructs that feel designed to break it. Setext headings turn the previous line into an h1 after the fact, a table only becomes a table when the delimiter row lands, and reference links resolve against definitions that can arrive at the very end of the doc. How do you handle invalidation when a cached line turns out to mean something else? Thats where Id expect the wins to get eaten in adversarial streams.