Warning
Experimental, for Inkdrop only. This is a scratch project I'm using to evaluate Markdown parser options for the Inkdrop desktop app's renderer pipeline. The fixtures, parser options, and architectural framing (Option M / IPC / WASM-in-renderer) all reflect Inkdrop's specific constraints — they aren't a general-purpose comparison. Numbers are point-in-time measurements on one machine; library versions are pre-1.0. Don't quote these numbers out of context.
Compares three Markdown → MDAST parsers in Node and inside an Electron renderer:
| Parser | Type | Notes |
|---|---|---|
mdast-util-from-markdown + micromark-extension-gfm + mdast-util-gfm |
Pure JS | What remark-parse uses under the hood — the baseline @inkdropapp/markdown ships today |
@inkdropapp/markdown-rs-node |
Rust via NAPI-rs | Thin to_mdast wrapper over wooorm/markdown-rs. Returns a fully-materialized MDAST tree. |
satteri |
Rust via NAPI-rs + WASI | Full markdown / MDX pipeline. Returns a lazy MDAST tree whose per-node fields materialize on access. Ships both native and WASM (wasm32-wasip1-threads) builds. |
pnpm installThe bench depends on a local checkout of @inkdropapp/markdown-rs-node at ../markdown-rs-node (since 0.0.1 on npm is missing prebuilt artifacts). The first bench:electron run will download Electron's binary.
pnpm bench # Node + mitata, all three parsers
pnpm bench:electron # Electron renderer (Chromium V8), satteri native
pnpm bench:electron:wasm # Electron renderer, satteri WASM (NAPI_RS_FORCE_WASI=1)The Electron bench is driven by Playwright's _electron; output streams to the parent terminal via page.on('console').
Three sizes in fixtures.mjs:
| Name | Size | Content |
|---|---|---|
small |
~0.5 KB | README-style document |
medium |
~6 KB | GFM-heavy: tables, tasklists, footnotes, code blocks, blockquotes |
large |
~56 KB | medium × 10 |
GFM mode. Two groups per case:
- Parse only — what each library does by default. Note: satteri returns a lazy tree, so this measures parse work without materialization.
- Parse + walk —
JSON.stringifyon the result, forcing full materialization (the apples-to-apples comparison if the tree must cross an IPC boundary or be handed to a non-lazy consumer likeremark-rehype).
| Parser | Parse only | Parse + walk | Peak alloc (walk) |
|---|---|---|---|
js fromMarkdown(+gfm) |
60.35 ms | 63.54 ms | ~22 MB |
markdown-rs-node toMdast |
16.98 ms | 18.32 ms | 3.17 MB |
markdown-rs-node toMdastJson |
13.11 ms | — | 0.91 MB |
satteri markdownToMdast |
0.43 ms (lazy) | 9.21 ms | 13.55 MB |
Speedup over JS baseline: 6.9× (satteri walk), 3.5× (markdown-rs-node), 140× (satteri lazy).
| Parser | Parse only | Parse + walk |
|---|---|---|
js fromMarkdown(+gfm) |
4.50 ms | 4.73 ms |
markdown-rs-node toMdast |
1.28 ms | 1.38 ms |
markdown-rs-node toMdastJson |
0.88 ms | — |
satteri markdownToMdast |
0.049 ms (lazy) | 0.91 ms |
| Parser | Parse only | Parse + walk |
|---|---|---|
js fromMarkdown(+gfm) |
~260 µs | ~290 µs |
markdown-rs-node toMdast |
81.91 µs | 87.17 µs |
markdown-rs-node toMdastJson |
56.50 µs | — |
satteri markdownToMdast |
6.43 µs (lazy) | 60.26 µs |
Same fixtures, GFM mode, large (~56 KB). webPreferences: nodeIntegration: true, contextIsolation: false, nodeIntegrationInWorker: true. Includes an Option M comparison: parse in main process, ship MDAST to renderer via IPC.
| Approach | Time | vs JS baseline |
|---|---|---|
js fromMarkdown(+gfm) (baseline) |
49.47 ms | 1.00× |
Option M: toMdast in main → IPC structured-clone |
26.82 ms | 1.85× |
markdown-rs-node toMdast in renderer (native) |
20.57 ms | 2.41× |
Option M: toMdastJson in main → IPC string → JSON.parse |
20.44 ms | 2.42× |
satteri WASM markdownToMdast in renderer |
6.90 ms | 7.17× |
satteri native markdownToMdast in renderer |
6.48 ms | 7.64× |
| Approach | Time | vs JS baseline |
|---|---|---|
js fromMarkdown(+gfm) (baseline) |
46.54 ms | 1.00× |
markdown-rs-node toMdast (renderer, native) |
20.26 ms | 2.30× |
markdown-rs-node toMdastJson (renderer, native) |
17.18 ms | 2.71× |
satteri WASM markdownToMdast (lazy, renderer) |
0.88 ms | 52.9× |
satteri native markdownToMdast (lazy, renderer) |
0.44 ms | 105.8× |
Comparing the same parser in-renderer vs. in-main + IPC:
| Path | In renderer | Option M (IPC) | IPC overhead |
|---|---|---|---|
markdown-rs-node toMdast (deep object) |
17.53 ms | 24.08 ms | +6.5 ms |
markdown-rs-node toMdastJson (string) |
14.96 ms | 17.82 ms | +2.9 ms |
String transport halves the structured-clone tax.
| Path | Native | WASM | WASM overhead |
|---|---|---|---|
| Lazy parse | 0.44 ms | 0.88 ms | 2.0× |
| Parse + walk | 6.48 ms | 6.90 ms | 1.06× |
WASM is only ~6% slower than native on the realistic "walk every node" path — most of the cost is JS-side materialization, not the Rust→V8 path.
| Artifact | Raw | gzip -9 | brotli -q 11 |
|---|---|---|---|
markdown-rs-node native (.node) |
799 KB | — | — |
satteri native (.node) |
2.4 MB | — | — |
satteri WASM (.wasm) |
2.3 MB | 673 KB | 464 KB |
-
All three Rust parsers beat the JS baseline by a large margin. Smallest win: 1.85× (Option M, structured-clone). Largest win: 105× (satteri native, lazy parse, in renderer).
-
satteri returns a lazy tree backed by a Rust-side buffer. Its 30–140× parse-only headlines collapse to 1.4–2× once you force materialization. For IPC transport (which serializes the whole tree), the lazy advantage disappears entirely.
-
markdown-rs-node ships
toMdastJson— a pre-encoded string transport. Rust serializes MDAST to a JSON string; renderer doesJSON.parse. Smallest IPC payload, smallest main-process allocation. Roughly halves the IPC tax compared to structured-cloning a deeptoMdastobject. -
The IPC tax is real but not dominant. ~3 ms for
toMdastJsonover IPC, ~6.5 ms fortoMdaststructured-clone on the large 56 KB GFM doc. Real cost; not catastrophic. -
In-renderer parsing wins decisively when it's allowed. Best Option M (20.44 ms) vs. best in-renderer (satteri WASM + walk, 6.90 ms): 2.96× speedup. Lazy parse (0.88 ms): 23× if the consumer can read on demand.
-
satteri's WASM build is only ~6% slower than native on the realistic "walk every node" path. Most cost is JS-side materialization, not the underlying Rust path.
fixtures.mjs— markdown fixtures (small/medium/large)bench.mjs— Node bench harness (mitata)bench-renderer.js— Electron-renderer bench (esbuild-bundled)build-renderer.cjs— esbuild bundler configelectron-main.cjs— Electron main process: IPC handlers forparse:toMdast/parse:toMdastJson, COOP/COEP headers for SharedArrayBufferelectron-renderer.html— renderer entryrun-electron-bench.mjs— Playwright_electrondriver