Published, reproducible numbers comparing this library with the other
streaming-markdown renderers people actually evaluate against
(Streamdown,
react-markdown with and without
the recommended block memoization,
smd / streaming-markdown,
and Incremark, with
marked as the full-re-parse baseline).
Filed as #157; the harness lives in
bench/competitors/. Every library name in the
generated results tables links to its GitHub project.
These numbers are deliberately not a CI gate. Competitor installs are
flaky and competitor releases change speed without any action of ours, so the
bench-competitors.yml workflow
runs on a monthly schedule or manual dispatch and publishes an artifact. Our
own relative regression guard (npm run bench, #154) is what CI gates on.
Adopted from Incremark's published benchmark
(benchmark-compare/,
MIT), then extended where their setup under-measures what a streaming UI
actually does:
- Chunked replay. Each fixture is streamed in small fixed-size chunks
(default 5 characters, matching Incremark's harness), appending to an
accumulated document exactly the way LLM deltas arrive. By default the
chunk size grows so no fixture exceeds 200 updates (keeps the O(n²)
full-re-render competitors from dominating wall-clock, and bounds a jsdom
memory quirk — see below); pass
--parityfor Incremark's exact uncapped 5-character methodology (best combined with--onlyto compare the pipeline-tier parsers, which is all Incremark's own table compares). - One process per (fixture × library) cell. DOMPurify's string-return
mode retains ~1–2 MB per call under jsdom — surviving GC and window
teardown; an upstream jsdom retention that real browsers don't exhibit
(
bench/competitors/dom-setup.tsdocuments the probes). Each cell therefore runs in its own child process so retention can never accumulate across the corpus, and a competitor crash loses one cell, not the run. - Median of runs. Every (library, fixture) cell is the median-by-total of 3 measured runs after 1 warmup, with per-update p50/p95/max recorded.
- Two execution tiers, reported separately:
- Pipeline — headless per-chunk work, no DOM. This is what Incremark's
published table measures: its
parser.append()(incremental mdast build) against other libraries' full re-parse. Caveat inherited with the methodology: the per-chunk work is not equivalent across libraries — Incremark builds an AST but renders nothing, Streamdown'sparseMarkdownIntoBlocksonly splits blocks with marked's lexer, and our entries emit a complete (optionally sanitized) HTML string every chunk. - DOM — end-to-end: chunks in, live rendered DOM updated per chunk, in
jsdom so every library pays identical DOM overhead. React-based
competitors render through
createRoot+flushSyncper chunk (what a per-delta re-render does in an app); smd and our incremental renderer mutate the DOM directly. This is the tier that answers "what does the user's frame budget see", and per-update p95/max on the long-transcript fixture is the frame-time proxy.
- Pipeline — headless per-chunk work, no DOM. This is what Incremark's
published table measures: its
- Output validation. A speed number is meaningless if the library got
there by rendering less, so after each DOM-tier contestant streams a fixture
we read its rendered DOM back and measure what it produced — the same way
for every library: visible-text length, the fraction of the source's word
tokens that reached that visible text (coverage), and counts of the
structural elements the markdown implies (headings, code blocks, tables,
list items, links, emphasis). Word tokens are alphanumeric runs of ≥ 4
characters, tokenized identically over the source and over each library's
rendered text. Columns that track together are the evidence that the timing
table compares equivalent work — that a library posting a 30× win is doing
so architecturally, not by silently dropping content. (This is where smd's
numbers hold up: on the long transcript it renders the same headings, code
blocks, tables and list items as ours, at near-identical coverage — its lead
is the append-only architecture, not skipped output.) The read is taken after
the DOM settles: async renderers (Streamdown highlights and re-parses in
effects that commit after
flushSyncreturns) are polled across macrotasks until their output stops growing, so their coverage reflects the true final document — not the near-empty synchronous snapshot. This settle applies to the validation read only; the timing table still measures the synchronous window (see the async-rendering caveat below).raw-html-detailsis exempt from the coverage read: ours deliberately holds the pending tail inside the still-open<details>(#138), so lower coverage there is correct behaviour, not a defect — see the corpus note. - Bundle size — esbuild, browser platform, minified, es2022, code splitting; React and declared peer dependencies are external (all React competitors assume an existing React app). "Initial" counts the entry plus all statically imported chunks; "total" adds lazy chunks.
Incremark's published "6.1× vs Streamdown across 38 real files" corpus is
not in its repository — only two of the files are committed. We pin those
two at commit 765e77b (SHA-256-verified at fetch time, MIT) for direct
comparability, and fill the rest of the corpus with real documents from this
repository plus the code-block-heavy case from #155:
incremark/P1.5_COLOR_SYSTEM_REPORT.md,incremark/test-md.md— the two committed upstream fixtures (fetched byfetch-corpus.mts, gitignored).README.md,CHANGELOG.md,docs/ARCHITECTURE.md,tests/fixtures/terms-of-service-streaming.md— real docs, mixed prose / lists / tables / code.synthetic/code-heavy (#155)— fenced-code-dominated (TS + Python + JSON), the shape where repeated re-highlighting/re-parsing goes super-linear.synthetic/long-transcript— all of the above concatenated, the long-conversation case where incremental architectures separate from re-render-everything ones.synthetic/raw-html-details (#0004)— an unclosed raw<details>early in the stream, then code-heavy content: the unbounded-fallback trap of ADR 0004, kept out of the long-transcript concatenation (an open container legitimately re-parents everything after it). Do not read this fixture's cells as a cross-library speed comparison — libraries do semantically different work on it. Ours honors the disclosure semantics: the pending tail is held while the widget is open (#138), and under passthrough the committed content sits inside a genuinely collapsed element the browser doesn't lay out — so our sanitized/unsafe rows do far less visible work by design. smd renders the tag as literal text and streams everything visibly. The row exists to regression-guard our own former O(n²) fallback (multi-second on this fixture before the ADR 0004 Phase 2 re-rooting); the parity row (htmlPolicy: 'escape-all', tag literalized, nothing held) is the only cell comparable with smd here, and it shows the usual ~2–4× architectural gap.
- Work per update differs by design. Our renderers emit
sanitizer-verified HTML every update (DOMPurify under jsdom — the
native-Sanitizer fast path measured by
npm run bench:browserdoesn't exist there, so our sanitized numbers are worst-case). smd writes DOM text nodes with no sanitizer; React-based renderers rely on React's escaping; Streamdown additionally hardens URLs and repairs incomplete markdown; Incremark's core builds an AST and renders nothing at all. - Streamdown renders asynchronously — its DOM-tier time is not
comparable. The output-validation pass surfaced this: after a synchronous
flushSyncchunk, Streamdown's DOM is near-empty (it highlights and re-parses in effects that commit a few ticks later); its full output only appears once the microtask/macrotask queue drains. The validation read waits for that settle, so Streamdown's coverage is truthful — but the timing table does not: it measures only the synchronous window, so Streamdown's DOM-tier milliseconds understate its real per-update cost (the async render lands off the timed critical path). Read Streamdown's DOM-tier time as a lower bound, not an end-to-end figure; its coverage/structure columns are the trustworthy part. One structure-column wrinkle, also surfaced by the validation: Streamdown wraps inline emphasis and links in styled<span data-streamdown="…">rather than semantic<a>/<strong>/<em>, so its links and emphasis counts read near-zero even though that text is present (its coverage is ~99%) — a semantics/accessibility difference, not dropped content. - Like-for-like vs smd: the DOM tier includes two unsafe variants of
ours with sanitization disabled (
ours DOM incremental (unsafe)via a passthrough backend, and therenderMarkdownUnsafestring export swapped in withinnerHTML) — smd's configuration, since it has no sanitizer and our highlighter/math/mermaid/emoji are likewise not loaded here. On top of those,ours DOM incremental (smd parity)is the fully like-for-like row: the unsafe configuration plus every grammar feature smd does not support turned off viaMarkdownConfig— GFM footnotes (footnotes: false) and link reference definitions (linkReferences: false), which also removes their per-update definition scans; email autolinks (emailAutolinks: false); and raw HTML (htmlPolicy: 'escape-all'— every tag literalizes except the void<br>, exactly smd's render-tags-as-text behaviour, and the policy under which the streaming path runs no raw tag-balance guards at all). What stays enabled matches smd's own README checklist: tables, task lists, strikethrough, barehttp(s)autolinks. (Residual asymmetries, all negligible on this corpus: smd tokenizes$…$math which our parity row leaves off, and we keep setext headings, GFM alerts, andwww.autolinks, which smd lacks.) The gap that remains in the parity row is purely architectural: smd appends tokens and never revisits committed output, while our renderer re-tokenizes the stream from the last safe boundary and morphs the pending tail (what buys mid-stream GFM correctness) — seedocs/decisions/0004for the plan to close it. - Highlighting: no highlighter is registered for our renderer, react-markdown and smd render code as plain text, so those compare markdown work. Streamdown's and Incremark-react's built-in highlighting is part of their defaults and left enabled — their code-heavy numbers include it.
- jsdom is not a browser. Layout, paint, style recalc and CLS do not
exist in jsdom; the DOM tier measures JS + DOM-tree work only. The
real-browser tier (
npm run bench:competitors:browser,bench-browser-live.mts) replays the same corpus with the same chunking in Chromium via playwright-core, forcing a synchronous layout flush after every update, so per-update numbers include style recalc + layout (paint/raster excluded). It covers the direct-DOM contestants the architectural question is about — our sanitized/unsafe/smd-parity configurations and smd — and writesbench/competitors/results/browser-latest.md; it is the sizing input for ADR 0004 Phase 0. In a real Chromium our sanitized row uses the native Sanitizer (Element.setHTML) when available — the path jsdom cannot measure. - Versions drift. Every published table embeds the exact competitor versions and machine; compare libraries within one run only, never across runs or machines.
npm ci # root deps (harness imports src/ directly)
npm ci --prefix bench/competitors # isolated competitor deps
npm run bench:competitors # fetches the pinned corpus, runs, writes results/Useful flags (append after --): --parity (exact Incremark methodology),
--fixture <regex>, --only <regex> (contestant filter), --iters N,
--chunk N, --skip-bundle, --no-isolate (single-process, for debugging),
--update-docs (rewrites the results section below). Output lands in
bench/competitors/results/latest.{json,md} — the same files the scheduled
workflow uploads as its artifact.
Last run: 2026-07-13 — node v22.22.2, Intel(R) Xeon(R) Processor @ 2.80GHz, chunk=5, capped at 200 updates/fixture, median of 3 runs. Versions: ours 0.11.0, incremark 1.0.2, streamdown 2.5.0, marked 16.4.2, smd 0.2.15, react-markdown 10.1.0.
| fixture | chars | updates | ours DOM incremental | ours string→innerHTML | ours DOM incremental (unsafe) | ours unsafe→innerHTML | ours DOM incremental (smd parity) | smd (streaming-markdown) | ours react (StreamingMarkdown) | react-markdown | react-markdown + memo blocks | streamdown | incremark react |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| incremark/P1.5_COLOR_SYSTEM_REPORT.md | 9338 | 199 | 358 ms | 4670 ms | 156 ms | 1618 ms | 164 ms | 15.6 ms | 508 ms | 1399 ms | 415 ms | 360 ms | 2133 ms |
| incremark/test-md.md | 18091 | 199 | 610 ms | 12505 ms | 276 ms | 4206 ms | 289 ms | 26.0 ms | 751 ms | 5043 ms | 901 ms | 1417 ms | 4324 ms |
| README.md | 9000 | 200 | 470 ms | 3873 ms | 225 ms | 1362 ms | 223 ms | 21.4 ms | 526 ms | 1280 ms | 578 ms | 643 ms | 1024 ms |
| CHANGELOG.md | 14984 | 200 | 618 ms | 9597 ms | 311 ms | 3462 ms | 287 ms | 24.4 ms | 765 ms | 3529 ms | 795 ms | 1058 ms | 2064 ms |
| docs/ARCHITECTURE.md | 49982 | 200 | 2298 ms | 25883 ms | 1167 ms | 9339 ms | 1229 ms | 50.8 ms | 2465 ms | 7398 ms | 3778 ms | 3718 ms | 5982 ms |
| tests/fixtures/terms-of-service-streaming.md | 5220 | 194 | 283 ms | 3414 ms | 139 ms | 1183 ms | 128 ms | 15.4 ms | 340 ms | 1015 ms | 327 ms | 334 ms | 751 ms |
| synthetic/code-heavy (#155) | 12285 | 199 | 413 ms | 1743 ms | 259 ms | 724 ms | 270 ms | 10.3 ms | 441 ms | 440 ms | 297 ms | 176 ms | 418 ms |
| synthetic/long-transcript | 118942 | 200 | 2838 ms | 111062 ms | 1513 ms | 27067 ms | 1396 ms | 95.9 ms | 3326 ms | 28716 ms | 4826 ms | 35484 ms | 21925 ms |
| synthetic/raw-html-details (#0004) | 12343 | 200 | 49.1 ms | 1342 ms | 32.7 ms | 836 ms | 265 ms | 14.2 ms | 112 ms | 474 ms | 301 ms | 208 ms | 423 ms |
| library | mean/update | p50 | p95 | max |
|---|---|---|---|---|
| ours DOM incremental | 14.2 ms | 8.00 ms | 20.1 ms | 513 ms |
| ours string→innerHTML | 555 ms | 545 ms | 1062 ms | 1799 ms |
| ours DOM incremental (unsafe) | 7.56 ms | 4.79 ms | 12.8 ms | 181 ms |
| ours unsafe→innerHTML | 135 ms | 139 ms | 239 ms | 301 ms |
| ours DOM incremental (smd parity) | 6.98 ms | 4.07 ms | 12.4 ms | 182 ms |
| smd (streaming-markdown) | 0.48 ms | 0.36 ms | 0.74 ms | 6.95 ms |
| ours react (StreamingMarkdown) | 16.6 ms | 9.87 ms | 22.2 ms | 497 ms |
| react-markdown | 144 ms | 155 ms | 240 ms | 291 ms |
| react-markdown + memo blocks | 24.1 ms | 22.6 ms | 52.3 ms | 72.2 ms |
| streamdown | 177 ms | 155 ms | 375 ms | 410 ms |
| incremark react | 109 ms | 96.9 ms | 265 ms | 395 ms |
After each contestant streams a fixture, its rendered DOM is read back and measured — so the timing table above can be trusted to compare equivalent work, not reward a library for silently dropping content. Same metric for every library. Word-token coverage of the visible text, per fixture (columns that track together mean everyone rendered the same document):
| fixture | ours DOM incremental | ours string→innerHTML | ours DOM incremental (unsafe) | ours unsafe→innerHTML | ours DOM incremental (smd parity) | smd (streaming-markdown) | ours react (StreamingMarkdown) | react-markdown | react-markdown + memo blocks | streamdown | incremark react |
|---|---|---|---|---|---|---|---|---|---|---|---|
| incremark/P1.5_COLOR_SYSTEM_REPORT.md | 98.9% | 98.9% | 98.9% | 98.9% | 98.9% | 97.9% | 98.9% | 98.9% | 98.9% | 96.8% | 98.9% |
| incremark/test-md.md | 99.0% | 99.0% | 99.0% | 99.0% | 99.0% | 99.0% | 99.0% | 99.0% | 99.0% | 98.5% | 98.5% |
| README.md | 99.0% | 99.0% | 99.0% | 99.0% | 99.0% | 92.1% | 99.0% | 99.0% | 98.5% | 99.0% | 98.0% |
| CHANGELOG.md | 99.7% | 99.7% | 99.7% | 99.7% | 99.7% | 96.1% | 99.7% | 99.7% | 99.7% | 99.7% | 97.1% |
| docs/ARCHITECTURE.md | 99.8% | 99.8% | 99.8% | 99.8% | 99.8% | 94.8% | 99.8% | 99.9% | 99.9% | 99.8% | 99.6% |
| tests/fixtures/terms-of-service-streaming.md | 93.3% | 93.3% | 93.3% | 93.3% | 93.3% | 96.1% | 93.3% | 99.6% | 98.0% | 90.9% | 89.0% |
| synthetic/code-heavy (#155) | 97.2% | 97.2% | 97.2% | 97.2% | 97.2% | 96.2% | 97.2% | 97.2% | 96.2% | 96.2% | 97.2% |
| synthetic/long-transcript | 99.1% | 99.1% | 99.1% | 99.1% | 99.1% | 96.2% | 99.1% | 99.7% | 99.5% | 99.0% | 98.1% |
| synthetic/raw-html-details (#0004) | 96.4% | 96.4% | 96.4% | 96.4% | 97.3% | 96.4% | 96.4% | 97.3% | 96.4% | 96.4% | 97.3% |
Rendered structure on the long transcript — element counts should track across libraries (the raw-html-details fixture is exempt: ours deliberately holds the tail inside the open <details>, so its coverage there is expected to differ — see the corpus note):
| library | visible text | coverage | headings | code | tables | list items | links | emphasis |
|---|---|---|---|---|---|---|---|---|
| ours DOM incremental | 103966 chars | 99.1% | 260 | 73 | 11 | 490 | 58 | 279 |
| ours string→innerHTML | 103966 chars | 99.1% | 260 | 73 | 11 | 490 | 58 | 279 |
| ours DOM incremental (unsafe) | 103966 chars | 99.1% | 260 | 73 | 11 | 490 | 58 | 279 |
| ours unsafe→innerHTML | 103966 chars | 99.1% | 260 | 73 | 11 | 490 | 58 | 279 |
| ours DOM incremental (smd parity) | 103977 chars | 99.1% | 260 | 73 | 11 | 490 | 58 | 279 |
| smd (streaming-markdown) | 103202 chars | 96.2% | 259 | 74 | 11 | 489 | 70 | 280 |
| ours react (StreamingMarkdown) | 103966 chars | 99.1% | 260 | 73 | 11 | 490 | 58 | 279 |
| react-markdown | 106006 chars | 99.7% | 260 | 73 | 0 | 490 | 47 | 279 |
| react-markdown + memo blocks | 105748 chars | 99.5% | 260 | 73 | 0 | 490 | 47 | 279 |
| streamdown | 104787 chars | 99.0% | 260 | 73 | 11 | 490 | 0 | 10 |
| incremark react | 104026 chars | 98.1% | 260 | 73 | 11 | 490 | 58 | 279 |
| fixture | chars | updates | ours renderMarkdownUnsafe | ours renderStreamingMarkdown | incremark core.append | streamdown parseMarkdownIntoBlocks | marked full re-parse |
|---|---|---|---|---|---|---|---|
| incremark/P1.5_COLOR_SYSTEM_REPORT.md | 9338 | 199 | 299 ms | 3646 ms | 13.9 ms | 47.2 ms | 50.8 ms |
| incremark/test-md.md | 18091 | 199 | 858 ms | 10933 ms | 32.1 ms | 281 ms | 240 ms |
| README.md | 9000 | 200 | 403 ms | 2574 ms | 48.8 ms | 9.59 ms | 96.7 ms |
| CHANGELOG.md | 14984 | 200 | 948 ms | 6636 ms | 33.9 ms | 246 ms | 214 ms |
| docs/ARCHITECTURE.md | 49982 | 200 | 2283 ms | 17121 ms | 1288 ms | 109 ms | 751 ms |
| tests/fixtures/terms-of-service-streaming.md | 5220 | 194 | 319 ms | 2370 ms | 16.0 ms | 62.7 ms | 74.3 ms |
| synthetic/code-heavy (#155) | 12285 | 199 | 31.8 ms | 1168 ms | 15.1 ms | 9.07 ms | 15.7 ms |
| synthetic/long-transcript | 118942 | 200 | 7877 ms | 94223 ms | 962 ms | 114 ms | 1617 ms |
| synthetic/raw-html-details (#0004) | 12343 | 200 | 39.5 ms | 828 ms | 15.8 ms | 14.3 ms | 18.0 ms |
| library | initial (min) | initial (min+gz) | total incl. lazy (min) | total (min+gz) | notes |
|---|---|---|---|---|---|
| ours (DOM + string core) | 100.9 kB | 33.3 kB | 100.9 kB | 33.3 kB | optional peers external (native Sanitizer path) |
| smd (streaming-markdown) | 14.7 kB | 4.3 kB | 14.7 kB | 4.3 kB | dependency-free |
| react-markdown | 114.8 kB | 35.5 kB | 114.8 kB | 35.5 kB | React runtime external (peer) |
| streamdown | 488.3 kB | 145.4 kB | 488.3 kB | 145.4 kB | React runtime external (peer); lazy chunks = mermaid etc. |
| incremark (@incremark/core) | 72.3 kB | 21.1 kB | 72.3 kB | 21.1 kB | parser only — no renderer |
| incremark (@incremark/react) | 9545.0 kB | 1837.3 kB | 9545.0 kB | 1837.3 kB | React runtime + katex/mermaid peers external; shiki bundles |
The tables above cap every fixture at 200 updates, which grows chunks to
75–600 characters. Real LLM deltas are a few characters at a time, and that
is the shape the pending-line fast path (#202) and the sealed-commit memos
(#203) target: most updates extend a plain prose sentence inside an otherwise
settled document. This section replays the two prose-bearing real documents
with Incremark's exact uncapped 5-character methodology
(npm run bench:competitors -- --parity --update-docs plus the
fixture/contestant filters recorded in the generated preamble). Incremental
DOM-tier contestants only — uncapped replay makes the full-re-render
competitors' cells take minutes without changing their story (they re-render
the whole document per update at any chunk size).
Last run: 2026-07-13 — node v22.22.2, Intel(R) Xeon(R) Processor @ 2.80GHz, chunk=5 (parity mode, uncapped), median of 3 runs. Fixture filter: terms-of-service-streaming|README. Contestant filter: DOM incremental|smd \(. Versions: ours 0.11.0, smd 0.2.15.
| fixture | chars | updates | ours DOM incremental | ours DOM incremental (unsafe) | ours DOM incremental (smd parity) | smd (streaming-markdown) |
|---|---|---|---|---|---|---|
| README.md | 9000 | 1800 | 1709 ms | 799 ms | 804 ms | 17.1 ms |
| tests/fixtures/terms-of-service-streaming.md | 5220 | 1044 | 834 ms | 301 ms | 280 ms | 18.5 ms |
Generated by npm run bench:competitors:browser -- --update-docs
(bench-browser-live.mts) —
same corpus and chunking as the jsdom tables above, per-update timing includes
a forced style recalc + layout. Direct-DOM contestants only (see the jsdom
caveat above for why); cells are total ms with per-update p95 in parentheses.
Real-browser tier: Chromium via playwright-core, layout forced per update (offsetHeight), 2026-07-13, node v22.22.2, Intel(R) Xeon(R) Processor @ 2.80GHz, chunk=5, capped at 200 updates/fixture, median of 3 runs. Our sanitized row used: DOMPurify backend.
| fixture | chars | updates | ours DOM incremental | ours DOM incremental (unsafe) | ours DOM incremental (smd parity) | smd (streaming-markdown) |
|---|---|---|---|---|---|---|
| incremark/P1.5_COLOR_SYSTEM_REPORT.md | 9338 | 199 | 146 ms (p95 1.60) | 80.9 ms (p95 0.90) | 68.4 ms (p95 0.80) | 34.7 ms (p95 0.30) |
| incremark/test-md.md | 18091 | 199 | 219 ms (p95 2.50) | 139 ms (p95 1.50) | 138 ms (p95 1.40) | 59.8 ms (p95 0.50) |
| README.md | 9000 | 200 | 170 ms (p95 1.70) | 97.8 ms (p95 1.00) | 107 ms (p95 1.20) | 21.5 ms (p95 0.20) |
| CHANGELOG.md | 14984 | 200 | 204 ms (p95 1.60) | 127 ms (p95 1.00) | 122 ms (p95 1.00) | 34.3 ms (p95 0.30) |
| docs/ARCHITECTURE.md | 49982 | 200 | 548 ms (p95 5.70) | 391 ms (p95 3.90) | 398 ms (p95 3.90) | 59.8 ms (p95 0.50) |
| tests/fixtures/terms-of-service-streaming.md | 5220 | 194 | 92.4 ms (p95 1.00) | 64.3 ms (p95 0.70) | 55.0 ms (p95 0.60) | 14.7 ms (p95 0.20) |
| synthetic/code-heavy (#155) | 12285 | 199 | 113 ms (p95 0.90) | 83.6 ms (p95 0.70) | 84.0 ms (p95 0.70) | 34.6 ms (p95 0.40) |
| synthetic/long-transcript | 118942 | 200 | 698 ms (p95 5.40) | 563 ms (p95 4.30) | 523 ms (p95 3.70) | 173 ms (p95 1.30) |
| synthetic/raw-html-details (#0004) | 12343 | 200 | 10.1 ms (p95 0.10) | 7.30 ms (p95 0.10) | 88.2 ms (p95 0.80) | 30.3 ms (p95 0.30) |