Stream words and markdown
@pierre/words is a super-fast, lightweight Markdown renderer hand-written in WebAssembly Text. Built for streaming conversations. Renders directly to HTML, supports GitHub Flavored Markdown, and pairs with @pierre/highlights for syntax highlighting. Includes incremental updates, HTML render hooks, and optional token fades. The base React renderer is 69% smaller than Streamdown in our gzip benchmark.
Markdown rendering benchmarks
Compare HTML throughput, React streaming time, and peak memory across five Markdown files.
Package benchmarks
November 10, 2026 · Node v24.11.0 · Apple M4 Max · 128 GiB RAM
gfm-spec.md · Median HTML throughput in MiB/s. Higher is better.
- Words
- 366.1MiB/s
- md4x (NAPI)0.0.29
- 168.5MiB/s
- md4x (Wasm)0.0.29
- 121.9MiB/s
- markdown-it15.0.0
- 29.7MiB/s
Browser measurements
Streaming updates
Source update latency, p95. Recorded November 10, 2026. Shorter is better.
- Words
- 2ms
- Streamdown
- 26.9ms
Frame p95: 17.6 ms for Words, 17.6 ms for Streamdown. Median of 3 runs, 267 updates each. Update latency excludes following paint.
Memory after streaming
Additional settled JS heap. Recorded November 10, 2026. Shorter is better.
- Words
- 5.7MB
- Streamdown
- 142.9MB
Median of 3 runs. V8 heap after forced garbage collection, relative to a warmed empty renderer. Excludes native DOM, Wasm, and graphics memory.
Browser download
Compressed JavaScript. Shorter is better.
- Words
- 247KB
- Streamdown
- 1,057.8KB
React is external. CSS is separate: 1.2 KB for Words, 0.4 KB for Streamdown. Both use GitHub light/dark and support the same 148 language requests. Words includes all 152 built-in languages. Streamdown loads languages on demand; this total includes the shared set and required embedded grammars.
Download measured November 11, 2026 at commit cb5b6305 · Bun 1.4.0.
Browser timings recorded November 10, 2026 at commit 8b0ad8f3 · Apple M4 Max · 128 GiB RAM · Chromium 134.0.6998.35 · React 19.2.7 · Streamdown 2.7.1.
Both libraries use their native streaming paths. The repeated fixture includes identical TypeScript fences; unique code and other languages may behave differently. Small documents stay near the display frame interval for both renderers. Raw browser results · Chart data · Benchmark source
How we measure
From a fresh checkout
Install the repository’s pinned toolchain and dependencies, then run these commands from its root. They measure HTML throughput, React stream replays in JSDOM, process memory, and the browser charts above.
moonx words:bench --ignore-ci-checks
moonx words:bench-stream --ignore-ci-checks
moonx words:bench-memory --ignore-ci-checks
moonx words:bench-browser --ignore-ci-checksSetup, methodology, and output files ↗What these charts cover
Timing uses 267 commit-paced deliveries, waiting 40 ms after each commit, in a 1280 × 960 viewport without auto-scrolling. Code controls and line numbers are disabled. Each value is the median of 3 fresh-browser runs’ p95 update times. Startup and first grammar loading are warmed before measurement. Heap is sampled in the same runs, before and after playback.
The 8.53 MB capacity diagnostic still stutters in both libraries. These single runs are excluded from the charts above. Playback stops after 180 seconds.
| Rendering | Words | Streamdown |
|---|---|---|
| Highlighting | 133.4 ms | 283.5 ms |
| Highlighting + fades | 200.1 ms | Timed out |
Fade behavior differs: Words animates delivered text chunks, including code; Streamdown animates prose words. Parser-only speed, browser update latency, and complete replay duration are separate measurements.
A softer arrival
Fade in new text with animated. Existing text keeps its place, and reduced-motion preferences are respected. Adjust the duration and replay to find the right pace.
A little room for better ideas
Good tools get out of your way. Great tools make you want to keep going.
This response arrives a few characters at a time. Watch emphasis, links, and code find their shape.
- Start with an idea
- Add a little more detail
- Keep the conversation going
The best part of a good tool is what you make with it.
Use it in React
import { Markdown } from '@pierre/words/react';
import '@pierre/words/styles.css';
<Markdown
isAnimating={isStreaming}
animated={{ animation: 'fadeIn', duration: 180 }}
lineNumbers={false}
>
{text}
</Markdown>Built for the conversation
Stream a response without waiting for the final token. Emphasis, links, lists, and late references settle into place as text arrives.
A little room for better ideas
Good tools get out of your way. Great tools make you want to keep going.
This response arrives a few characters at a time. Watch emphasis, links, and code find their shape.
- Start with an idea
- Add a little more detail
- Keep the conversation going
The best part of a good tool is what you make with it.
Use it in React
import { Markdown } from '@pierre/words/react';
import '@pierre/words/styles.css';
<Markdown
isAnimating={isStreaming}
animated={{ animation: 'fadeIn', duration: 180 }}
lineNumbers={false}
>
{text}
</Markdown>Code that keeps up
Add @pierre/highlights to highlight code as it streams. Toggle line numbers and replay the example to watch it take shape.
From a thought to a function
A tiny generator, with a little room to breathe.
async function* words(message: string) {
for (const word of message.split(' ')) {
yield word + ' ';
await new Promise((resolve) => setTimeout(resolve, 60));
}
}
for await (const word of words('Hello, world.')) {
process.stdout.write(word);
}
Inline code, like yield word, gets its own treatment. Unknown languages fall back to plain text.
Use it in React
import { Markdown } from '@pierre/words/react';
import '@pierre/words/styles.css';
import { createHighlightsPlugin } from '@pierre/words/highlights';
import light from '@pierre/highlights/themes/pierre-light';
import dark from '@pierre/highlights/themes/pierre-dark';
const plugins = {
code: createHighlightsPlugin({ themes: { light, dark }, defaultColor: 'light-dark()' }),
};
<Markdown
isAnimating={isStreaming}
animated={{ animation: 'fadeIn', duration: 180 }}
plugins={plugins}
>
{text}
</Markdown>Your HTML, your way
Customize the generated HTML with string hooks. Switch between the default output and styled headings, links, and inline code without changing the Markdown.
HTML render hooks
Make links feel at home in your app. Style inline code, add an icon to headings, and keep the same Markdown.
Keep your own style
- Return HTML strings from your hooks
- Let Words handle the streaming updates
- Use your own classes and design tokens
Use it in React
import { Markdown } from '@pierre/words/react';
import '@pierre/words/styles.css';
<Markdown
isAnimating={isStreaming}
animated={{ animation: 'fadeIn', duration: 180 }}
lineNumbers={false}
components={{
a: (html) => html.replace('<a ', '<a class="your-link" '),
}}
>
{text}
</Markdown>Take it for a longer run
Try your own Markdown or choose a larger document. Large samples wait for you to replay the stream before rendering their preview.
A little room for better ideas
Good tools get out of your way. Great tools make you want to keep going.
This response arrives a few characters at a time. Watch emphasis, links, and code find their shape.
Built for the conversation
- Write naturally, with GitHub Flavored Markdown
- Customize the generated HTML
- Make something worth sharing
import { Markdown } from '@pierre/words/react';
export function Message({ text, streaming }) {
return (
<Markdown isAnimating={streaming}>
{text}
</Markdown>
);
}
| A familiar idea | A thoughtful detail |
|---|---|
| Markdown | Tables, tasks, and footnotes |
| Streaming | Formatting before the final token |
| Code | Highlighted by Pierre Highlights |
The best part of a good tool is what you make with it.
Keep exploring Pierre. Every detail counts.1
-
Even the little ones at the bottom of the page. ↩
Use it in React
import { Markdown } from '@pierre/words/react';
import '@pierre/words/styles.css';
import { createHighlightsPlugin } from '@pierre/words/highlights';
import light from '@pierre/highlights/themes/pierre-light';
import dark from '@pierre/highlights/themes/pierre-dark';
const plugins = {
code: createHighlightsPlugin({ themes: { light, dark }, defaultColor: 'light-dark()' }),
};
<Markdown
isAnimating={isStreaming}
animated={{ animation: 'fadeIn', duration: 180 }}
plugins={plugins}
>
{text}
</Markdown>Ready as it arrives.
Unfinished emphasis and links render correctly while a response streams. Finished blocks are not rendered again.
Your HTML.
Customize headings, links, and code blocks with hooks that take and return HTML strings.
A small, capable core.
Tables, task lists, and footnotes from a WAT parser. Add Pierre Highlights when your words include code.