Skip to content

inkdropapp/markdown-parser-benchmark

Repository files navigation

Markdown parser benchmark

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.

Setup

pnpm install

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

Running

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').

Fixtures

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

Results — Node 25.9, Apple M3 Max

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 + walkJSON.stringify on 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 like remark-rehype).

Large (~56 KB)

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

Medium (~6 KB)

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

Small (~0.5 KB)

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

Results — Electron 42 renderer (Chromium 148, V8 14.8)

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.

Large (~56 KB), parse + walk

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×

Large (~56 KB), parse only (lazy — relevant if the consumer reads only part of the tree)

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×

IPC tax measured

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.

WASM vs native overhead (satteri, renderer)

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.

Binary / bundle sizes

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

Key findings

  1. 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).

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

  3. markdown-rs-node ships toMdastJson — a pre-encoded string transport. Rust serializes MDAST to a JSON string; renderer does JSON.parse. Smallest IPC payload, smallest main-process allocation. Roughly halves the IPC tax compared to structured-cloning a deep toMdast object.

  4. The IPC tax is real but not dominant. ~3 ms for toMdastJson over IPC, ~6.5 ms for toMdast structured-clone on the large 56 KB GFM doc. Real cost; not catastrophic.

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

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

Files

  • 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 config
  • electron-main.cjs — Electron main process: IPC handlers for parse:toMdast / parse:toMdastJson, COOP/COEP headers for SharedArrayBuffer
  • electron-renderer.html — renderer entry
  • run-electron-bench.mjs — Playwright _electron driver

About

Markdown parser performance benchmark: @inkdropapp/markdown-rs-node, satteri (native + WASM), and the mdast-util-from-markdown JS reference. Runs in Node and inside an Electron renderer (via Playwright), including an Option M IPC variant.

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors