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/*.mjsscripts 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
- serde-yaml / unsafe-libyaml (dtolnay): CONFIRMED dead. Final release
v0.9.34+deprecated; repo archived by owner Mar 25, 2024; maintainer said he no longer uses YAML (https://github.com/dtolnay/serde-yaml/releases, https://github.com/rustsec/advisory-db/issues/2132). unsafe-libyaml (c2rust auto-translation of libyaml) is likewise unmaintained and full of unsafe (https://github.com/dtolnay/unsafe-libyaml/issues/21). - serde_yml: avoid. AI-“maintained” fork of serde-yaml; Debian bug calls out “serious concerns about these forks” — AI-added code that is “complete nonsense or unsound”, docs.rs build broken by a hallucinated rustdoc flag (https://www.mail-archive.com/debian-bugs-dist@lists.debian.org/msg2018921.html); public backlash from dtolnay; RUSTSEC-2025-0068 flags all versions ≤0.0.12 as unsound (segfault via
Serializer.emitter), 0.0.13 is just a migration shim (https://users.rust-lang.org/t/serde-and-yaml-support-status/125684). - yaml-rust2 (Ethiraric): alive, maintenance mode. Pure-Rust, “fully compliant YAML 1.2”, passes the YAML test suite; “will receive only basic maintenance and keep a stable API” (https://raw.githubusercontent.com/Ethiraric/yaml-rust2/master/README.md). First-release post (2024-03-15) says it beat yaml-rust by 15–20% but “compared against the C libfyaml, there is still much room for improvement” — i.e. slower than the fast C parsers (https://github.com/Ethiraric/yaml-rust2/blob/master/documents/2024-03-15-FirstRelease.md).
- saphyr / saphyr-parser: the actively developed successor (same author group); accepts new features at cost of API churn; pure Rust YAML 1.2 (https://lib.rs/crates/saphyr-parser). serde-saphyr adds direct-to-struct deserialization without an intermediate value tree (https://github.com/bourumir-wyngs/serde-saphyr) — irrelevant for us since our output is untyped JS objects.
C
- libyaml: the conservative baseline. Maintained but slow-ish; “makes heavy use of allocations and string duplications when parsing” (rapidyaml’s alternative-libraries doc: https://rapidyaml.readthedocs.io/v0.7.1/sphinx_alternative_libraries.html).
- libfyaml (pantoniou): fastest fully-conformant C claim. “Fully feature complete YAML parser and emitter, supporting the latest YAML spec and passing the full YAML testsuite”; zero-copy; MIT; but the README gives no benchmark numbers, and current release line is 1.0-alpha (https://github.com/pantoniou/libfyaml, https://raw.githubusercontent.com/pantoniou/libfyaml/master/README.md). Third parties measure fkYAML ~70% faster than libfyaml on some workloads (https://github.com/fktn-k/fkYAML). yaml-rust2’s author treats libfyaml as the C speed reference.
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,%YAMLdirectives 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 unmaintainedyaml-rust0.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:
TextEncoder.encode: 2.3–4.5 GB/s;encodeIntointo a preallocated (wasm-heap-style) buffer: 9.6–21.7 GB/s;Buffer.from(str,'utf8'): 6–10 GB/s. Encoding the 10.7 MB fixture costs 1.1–3.3 ms. MDN notesencodeIntoexists precisely for encoding straight into a Wasm heap (https://developer.mozilla.org/en-US/docs/Web/API/TextEncoder/encodeInto).
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-rolledfromCharCodeloop 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_serdewas 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:
- USENIX ATC’19 “Not So Fast”: wasm runs SPEC 45% slower (Firefox) to 55% slower (Chrome) than native, mean 1.55×; cache/register-pressure induced (https://www.usenix.org/conference/atc19/presentation/jangda).
- Native Node addons ≈1.75–2.5× faster than the same code as wasm (https://nickb.dev/blog/wasm-and-native-node-module-performance-comparison/).
- esbuild’s own docs: “The WebAssembly version is much, much slower than the native version. In many cases it is an order of magnitude (i.e. 10x) slower” — partly Go-runtime-specific (https://esbuild.github.io/getting-started/).
- source-map 0.7 (Rust+wasm) was genuinely 5.3–10.8× faster than the old JS at its task (https://hacks.mozilla.org/2018/01/oxidizing-source-maps-with-rust-and-webassembly/), after Egorov showed algorithmic JS tuning closed most of the original gap (https://fitzgen.com/2018/02/26/speed-without-wizardry.html) — but the ecosystem still abandoned it: PostCSS et al. forked pure-JS
source-map-jsbecause the wasm version forced an async API and amappings.wasmURL bootstrap; source-map 0.6.1 out-downloads 0.7.3 2:1 (https://github.com/7rulnik/source-map-js, https://www.npmjs.com/package/source-map-js). - Wasm JSON parsers historically lose to JSON.parse once DOM materialization crosses the boundary (Chad Austin’s sajson-emscripten 2013: native 5–10× faster than Emscripten output, https://chadaustin.me/2013/02/sajson-emscripten-benchmarks/; V8’s JSON.parse runs 200–400 MB/s in-engine).
3. Packaging
Section titled “3. Packaging”- Base64-embedding the .wasm in JS is standard practice to dodge bundler/URL pain: shiki ships
shiki/wasmwith “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 custominitfor bundlers that can’t do wasm-ESM (https://www.npmjs.com/package/tiktoken); the anti-pattern (external file + asyncinitialize(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 aload()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”- Granularity: 64 KiB pages;
memory.growonly — the spec has no shrink; freed pages are reusable inside the module but never returned to the OS until the wholeWebAssembly.Memoryis GC’d; the memory-control proposal to fix this is still not shipped (https://github.com/WebAssembly/design/issues/1397, https://github.com/WebAssembly/memory-control/blob/main/proposals/memory-control/Overview.md, https://developer.mozilla.org/en-US/docs/WebAssembly/Reference/JavaScript_interface/Memory). - Verified locally:
Memory.growof 100 MB + touching pages ⇒ RSS +99 MB, permanently (synccompile.mjs). Everything the parser touches counts fully inprocess.resourceUsage().maxRSS— the exact metric of this repo’s memory harness (README.md:34-36). - Allocators overshoot: emscripten’s default geometric growth is +20% per grow, capped at 96 MB overshoot (
MEMORY_GROWTH_GEOMETRIC_STEP = 0.20,MEMORY_GROWTH_GEOMETRIC_CAP = 96MB, https://raw.githubusercontent.com/emscripten-core/emscripten/main/src/settings.js lines ~226-244). - Arithmetic for the 10 MB fixture via ryml tree + JSON emit: ryml
NodeData= NodeType + 2×NodeScalar(scalar/tag/anchor substr) + 5 indices (https://raw.githubusercontent.com/biojppm/rapidyaml/master/src/c4/yml/tree.hpp lines 287-299) ≈ 68–88 B on wasm32 ⇒ 730 K nodes ≈ 50–64 MB tree, + 10.7 MB source copy + ≥10.7 MB emitted JSON + overshoot ⇒ ~80–110 MB resident linear memory that never shrinks. Compare the repo’s committed table (README.md:38-42): 10 MB parse ×25 iters peaks at 282 MB (JSON) / 495 MB (js-yaml) / 2.63 GB (yaml). A tree-based wasm candidate ≈ JSON’s profile + ~100 MB + per-iter 21 MB JSON-string garbage ⇒ plausibly ~380–430 MB: below js-yaml, well above JSON. An event-streaming transcoder (no tree; saphyr-parser events or libfyaml event API → JSON bytes) drops linear memory to input+output ≈ 25–35 MB and is the memory-sane wasm design; ryml is tree-only.
5. Bottom line
Section titled “5. Bottom line”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.
KEY FACTS
Section titled “KEY FACTS”- 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)