Skip to content

WASM route for lightning-yaml: wrap a native YAML parser as a WASM npm package

Produced by a multi-agent research session (Claude Code). All local numbers were measured on the session container — Node v22.22.2 / V8 12.4, 4-vCPU Xeon 2.80 GHz, 16 GB RAM; absolute MB/s are machine-specific, ratios are the durable signal. Referenced scratchpad/*.mjs scripts were session throwaways and are not committed.

All local measurements below were run in this repo’s environment: Node v22.22.2, fixtures generated via pnpm gen:fixtures (952 B – 10.73 MB, /home/user/lightning-yaml/bench/fixtures/data/), scripts kept in /tmp/claude-0/-home-user-lightning-yaml/5b5afb90-7c63-5a56-af0b-8798d3e9b488/scratchpad/ (boundary-bench.mjs, callcost.mjs, nodecount.mjs, synccompile.mjs). Locally installed competition: js-yaml@4.3.0, yaml@2.9.0 (/home/user/lightning-yaml/node_modules/.pnpm/). NOTE: npm now has js-yaml@5.2.1 (2026-07-02), a full TypeScript rewrite (v5.0.0, 2026-06-20) with reworked schemas — the “beat js-yaml” target has moved since this repo pinned ^4.1.0 (https://raw.githubusercontent.com/nodeca/js-yaml/master/CHANGELOG.md, package.json:26).

1. Candidate native libraries (status as of mid-2026)

Section titled “1. Candidate native libraries (status as of mid-2026)”

Rust

C

C++

  • rapidyaml/ryml (biojppm): the speed king with receipts. Self-published numbers: YAML parse ~150 MB/s Linux (131.6–176.4 MB/s on travis.yml), ~100 MB/s Windows; JSON parse 388–457 MB/s (“same ballpark as RapidJSON”, which hits 1329 MB/s in-place); “2–3× faster than libyaml”, “10–70× faster than yaml-cpp” (https://rapidyaml.readthedocs.io/v0.7.2/sphinx_is_it_rapid.html, https://github.com/biojppm/rapidyaml). Passes 100.00% of the YAML test suite, but with deliberate deviations: container keys prohibited (scalar keys only — actually fine for JS objects), tabs after :/- rejected by default, %YAML directives ignored, duplicate keys permitted (README). MIT, tested on x64/arm/aarch64/ppc64le/s390x and emscripten; README says “a JavaScript+WebAssembly port is available, compiled through emscripten” but it is explicitly a work in progress and there is no npm package: npm registry: rapidyaml → Not found (verified via registry query).

Existing yaml-via-wasm npm packages: essentially none alive.

  • yaml-wasm (KSXGitHub): wasm-bindgen wrapper around the old unmaintained yaml-rust 0.4 + linked-hash-map (Cargo.toml verified: https://raw.githubusercontent.com/KSXGitHub/yaml-wasm/master/Cargo.toml); repo archived Mar 7, 2022; no benchmarks vs js-yaml published (https://github.com/KSXGitHub/yaml-wasm).
  • npm registry search “yaml wasm”: top hit is a pre-built tree-sitter-yaml WASM grammar (CST for editors, not a plain-object parser); no maintained parse-to-JS-object wasm YAML package exists. rapidyaml, libfyaml, wasm-yaml, yaml_wasm — all 404 on the registry (verified 2026-07-12).
  • Conclusion: the niche is empty — nobody has demonstrated wasm YAML beating js-yaml on npm; the one attempt died without benchmarks.

2. The boundary problem, quantified (measured locally on Node 22.22.2)

Section titled “2. The boundary problem, quantified (measured locally on Node 22.22.2)”

Input side is a non-problem. Measured with boundary-bench.mjs on the repo’s own fixtures:

Baselines on the same fixtures (best-of-run): JSON.parse 151–203 MB/s (xlarge: 70.8 ms); js-yaml.load 24–29 MB/s (xlarge: 449 ms); yaml.parse ~2 MB/s (matches the repo’s known results). So js-yaml is ~6× slower than JSON.parse; the wasm route must land in between.

Output side option (a): per-node calls from wasm into JS — measured, and it’s the killer.

  • Hand-assembled wasm module (callcost.mjs): wasm→JS import call ≈ 15.2–15.9 ns; JS→wasm export call ≈ 10.6–10.8 ns; JS→JS call ≈ 7.8–9.7 ns. So the raw boundary overhead is tiny (~5–6 ns) on modern V8 — consistent with the 2018 call-optimization work (https://hacks.mozilla.org/2018/10/calls-between-javascript-and-webassembly-are-finally-fast/).
  • But a callback that merely allocates {v: x} costs ≈126 ns/call, and materializing each string from linear memory costs TextDecoder.decode(subarray) ≈128–141 ns/call for 8–64-byte strings (a hand-rolled fromCharCode loop wins below ~16 bytes at ~77 ns, loses above).
  • Node counts (nodecount.mjs): the fixtures carry ~68,000 nodes/MB (xlarge: 729,516 nodes, 297,083 string scalars + 540,540 map keys = 837,623 strings). Per-node math for 10 MB: ~730 K structure callbacks × ~126 ns ≈ 92 ms + ~838 K string materializations × ~130 ns ≈ 109 ms ⇒ ≈200 ms of pure boundary/materialization before any parsing — versus JSON.parse doing the entire job (same objects, same strings, engine-internal) in 71 ms. This is exactly why serde-wasm-bindgen-style object building loses: “the cost was never in the computation — it was always in data transfer across the WASM-JS boundary” (https://github.com/wasm-bindgen/wasm-bindgen/issues/2539, https://www.openui.com/blog/rust-wasm-parser).
  • The wasm-bindgen guide itself documents that the JSON-string route “can be anywhere from 2x to 0.2x the speed of serde-wasm-bindgen” and recommends profiling; JsValue::from_serde was deprecated partly over this (https://wasm-bindgen.github.io/wasm-bindgen/reference/arbitrary-data-with-serde.html, https://github.com/wasm-bindgen/wasm-bindgen/pull/3031).

Output side option (b): transcode YAML→JSON inside wasm, then native JSON.parse — the proven pattern.

  • Direct precedent (NAPI, same boundary economics): swc and oxc ship their ASTs to JS as JSON strings and call JSON.parse, because it measured faster than building JS objects through the FFI; oxc issue #2409 “Faster passing ASTs from Rust to JS” documents this and the later raw-arena-buffer escape hatch (https://github.com/oxc-project/oxc/issues/2409, https://github.com/swc-project/swc/issues/2175, https://medium.com/@hchan_nvim/benchmark-typescript-parsers-demystify-rust-tooling-performance-025ebfd391a3 — also shows V8-side JS deserializer code beating Rust-side rkyv serialization by 2×).
  • Cost of the extra hop measured here: TextDecoder on MB-scale buffers runs ~2.0–2.1 GB/s (10.7 MB → 5.2 ms), and decode+JSON.parse ≈ JSON.parse + ~10% (78.3 vs 70.8 ms on xlarge). So the “double parse” tax = JSON emit inside wasm (fast, ryml emits JSON from a tree at RapidJSON-ballpark rates natively) + ~5 ms decode + the unavoidable 71 ms JSON.parse.
  • Nobody has shipped this specific trick for YAML on npm (searched; only CLI/JS-level yaml→json converters exist). It would be a first — with real semantic caveats: .inf/.nan, non-string keys, YAML 1.1 timestamps→Date, and anchors/aliases must be expanded during transcode (alias bombs amplify the JSON), none of which round-trip through JSON. Fine for JSON-bytes fixtures; a real conformance cost for the product.

Known end-to-end wasm-vs-native/JS results:

  • Base64-embedding the .wasm in JS is standard practice to dodge bundler/URL pain: shiki ships shiki/wasm with “the wasm binary inlined as a base64 string” as an ES module (https://nuxt.com/blog/shiki-v1, https://shiki.style/guide/bundles); tiktoken documents custom init for bundlers that can’t do wasm-ESM (https://www.npmjs.com/package/tiktoken); the anti-pattern (external file + async initialize(url)) is what killed source-map 0.7 adoption. Cost: +33% npm bytes and a one-time decode — measured locally: base64→bytes for a 14 MB string = 5.5 ms (synccompile.mjs); a realistic 0.3–1 MB parser module decodes in <1 ms.
  • Sync init in Node: verified, no limit. Locally sync-compiled + instantiated a 10.49 MB module in 7.6 ms via new WebAssembly.Module(bytes) (synccompile.mjs). So a load() API with js-yaml parity (fully sync, lazy compile on first call) works on Node ≥20 unconditionally.
  • Browsers: the famous 4 KB main-thread cap on sync compile is Chrome-only history. Chrome deprecated/lifted it in M114 (desktop/Android/WebView) because V8’s lazy compilation keeps even the biggest modules under ~1 s on weak devices; an 8 MB figure was floated during the process (spec max function size 7,654,321 B); Firefox and Safari never had the cap (https://groups.google.com/a/chromium.org/g/blink-dev/c/nJw2zwaiJ2s/m/EYPgC5D3LwAJ, https://chromestatus.com/feature/5099433642950656, https://web.dev/articles/loading-wasm). Workers never had the limit. So a sync browser API is viable on 2023+ browsers; an async init() remains the polite default for old-Chrome support.

4. WASM linear memory vs the peak-RSS benchmark

Section titled “4. WASM linear memory vs the peak-RSS benchmark”

Pipeline estimate for the 10 MB fixture, using measured local numbers + ryml’s published native rates derated 1.45–2.5× for wasm (USENIX/nickb.dev): encodeInto ~1–3 ms + wasm YAML parse at 60–100 MB/s ⇒ 107–180 ms + JSON emit in wasm ~30–60 ms + TextDecoder 5 ms + JSON.parse 71 ms ≈ 215–320 ms end-to-end, vs js-yaml 449 ms and JSON.parse 71 ms. Scaling holds at 100 KB–1 MB (per-call overheads are µs-scale); at 1 KB the win compresses (js-yaml 32 µs vs est. ~15–25 µs) but doesn’t invert, since a hot instance needs no re-init.

  • Yes, wasm-wrapped ryml (or a Rust event-transcoder) would likely beat js-yaml@4 end-to-end: ~1.5–2× across 100 KB–10 MB — but only via the transcode-to-JSON+JSON.parse output path. The per-node-callback path burns ~200 ms/10 MB on boundary+materialization alone and would roughly tie js-yaml (and lose to any decent JS parser).
  • It cannot approach JSON.parse. JSON.parse is a mandatory component of the fast path, so end-to-end is strictly JSON.parse + transcode ⇒ realistically 3–4.5× JSON.parse time, i.e. it lands mid-way, closer to js-yaml than to JSON.
  • Peak RSS is a real liability, not a disqualifier: tree-based wasm ≈ between JSON and js-yaml; streaming-transcode wasm ≈ near-JSON. Linear memory’s never-shrink behavior means the first big document sets the process’s floor forever — visible and permanent in this repo’s maxRSS harness.
  • Strategic read: a 1.5–2× win over js-yaml is exactly the gap a well-engineered pure-JS parser can also reach (js-yaml runs at 24–29 MB/s here while JSON.parse-class engine work shows 150–200 MB/s headroom; Egorov’s “maybe you don’t need Rust+wasm” applies). The wasm route adds: +33%-inflated ~0.5–1.5 MB package, async-or-114+ browser init, no-shrink memory floor, JSON-semantics conformance holes (NaN/Inf, dates, aliases expansion), and dependence on a C++ codebase (ryml) whose wasm port is WIP with no npm presence — against a competitor (js-yaml 5, June 2026 rewrite) that just modernized. The evidence supports wasm as feasible but strategically weak: it wins the current benchmark against js-yaml@4, but it caps out far from JSON.parse and forfeits the packaging/memory story that a pure-JS implementation keeps.
  • Measured locally (Node 22.22.2, repo fixtures, scratchpad/boundary-bench.mjs): JSON.parse 151-203 MB/s (10.7MB in 70.8ms), js-yaml.load 24-29 MB/s (449ms), yaml.parse ~2 MB/s; TextEncoder.encodeInto 9.6-21.7 GB/s, TextDecoder ~2 GB/s on MB buffers, decode+JSON.parse costs only ~10% over JSON.parse alone
  • Measured locally (scratchpad/callcost.mjs, hand-assembled wasm): wasm->JS import call ~15.5ns, but a callback allocating {v:x} costs ~126ns and TextDecoder.decode of 8-64B subarrays ~130ns; with ~68K nodes/MB (scratchpad/nodecount.mjs: xlarge = 729,516 nodes, 837,623 strings) the per-node output path burns ~200ms/10MB before any parsing — the transcode-to-JSON path is the only viable one
  • Precedent for the transcode trick: swc and oxc return AST as JSON string + JSON.parse because it beat per-field FFI object building (https://github.com/oxc-project/oxc/issues/2409); wasm-bindgen deprecated JsValue::from_serde and documents JSON route at 2x-0.2x serde-wasm-bindgen (https://wasm-bindgen.github.io/wasm-bindgen/reference/arbitrary-data-with-serde.html); no one has shipped it for YAML on npm
  • rapidyaml (ryml) is the fastest credible native lib: ~150MB/s YAML / ~450MB/s JSON parse native, 2-3x libyaml, 100% YAML test suite with deviations (scalar keys only, tabs, dup keys); emscripten port is WIP and there is NO npm package (registry 404 verified) (https://rapidyaml.readthedocs.io/v0.7.2/sphinx_is_it_rapid.html)
  • Rust lib status: serde-yaml/unsafe-libyaml archived Mar 25 2024 (https://github.com/dtolnay/serde-yaml/releases); serde_yml is an unsound AI-slop fork (RUSTSEC-2025-0068, Debian bug msg2018921); yaml-rust2 alive-but-maintenance and slower than libfyaml by its own post; saphyr is the active successor; libfyaml claims full conformance+zero-copy but is 1.0-alpha with no published numbers
  • Only prior art on npm, yaml-wasm (wraps dead yaml-rust 0.4 via wasm-bindgen), was archived Mar 7 2022 with no benchmarks (https://github.com/KSXGitHub/yaml-wasm) — the wasm-YAML niche is empty
  • Packaging: base64-inlining wasm is standard (shiki ships base64-inlined wasm ESM, https://nuxt.com/blog/shiki-v1); measured base64 decode of 14MB = 5.5ms; Node sync-compiles a 10.49MB module in 7.6ms with no size limit (scratchpad/synccompile.mjs); Chrome’s 4KB main-thread sync cap was lifted in M114 (https://groups.google.com/a/chromium.org/g/blink-dev/c/nJw2zwaiJ2s/m/EYPgC5D3LwAJ), Firefox/Safari never had it — sync load() parity with js-yaml works on Node and 2023+ browsers
  • WASM linear memory grows in 64KiB pages and can never shrink (https://github.com/WebAssembly/design/issues/1397); verified Memory.grow(100MB)+touch = permanent +99MB RSS; emscripten overshoots growth by 20% (settings.js MEMORY_GROWTH_GEOMETRIC_STEP=0.20); ryml tree costs ~68-88B/node on wasm32 (tree.hpp NodeData) => ~80-110MB permanent linear memory for the 10MB fixture vs README.md:38-42 baselines (JSON 282MB, js-yaml 495MB peak RSS)
  • End-to-end estimate for 10MB using measured+derated numbers (wasm 1.45-2.5x slower than native per USENIX ATC’19 and nickb.dev): ~215-320ms vs js-yaml 449ms and JSON.parse 71ms — i.e. beats js-yaml ~1.5-2x but lands at 3-4.5x JSON.parse; ecosystem precedent (source-map 0.7 wasm abandoned for pure-JS source-map-js fork over async init + wasm URL pain) argues a pure-JS parser with the same 1.5-2x win is strategically stronger
  • Competition moved: js-yaml 5.0.0 (2026-06-20) is a full TypeScript rewrite, 5.2.1 current (https://raw.githubusercontent.com/nodeca/js-yaml/master/CHANGELOG.md); repo still pins ^4.1.0 (package.json:26, installed 4.3.0)