Beta

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.

Explore the docs

Markdown rendering benchmarks

Compare HTML throughput, React streaming time, and peak memory across five Markdown files.

How we measure ↓

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-checks
Setup, 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.

Frame p95 · 8.53 MB · single diagnostic runs
RenderingWordsStreamdown
Highlighting133.4 ms283.5 ms
Highlighting + fades200.1 msTimed 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.

Speed
PreviewReady

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.

376 / 376 charactersEdit the source or replay the response.
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.

PreviewReady

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.

376 / 376 charactersEdit the source or replay the response.
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.

PreviewReady

From a thought to a function

A tiny generator, with a little room to breathe.

typescript
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.

461 / 461 charactersEdit the source or replay the response.
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.

PreviewReady

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
303 / 303 charactersEdit the source or replay the response.
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.

Editable
PreviewReady

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
tsx
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

  1. Even the little ones at the bottom of the page. ↩

987 / 987 charactersEdit the source or replay the response.
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.