WASM spike: nodeToTypst port — verdict report

Status: CLOSED. Decision recorded; spike branch deleted.

Branch (deleted): wasm-spike temporary worktree. Commits 0563250, 16a7208, 55f5194, 07c8924 preserved in git reflog only; no longer reachable from any named ref. Findings extracted to this report and wiki/content/architecture/PRINCIPLES.md §1a.

Date: 2026-05-11

Subsequent investigation: Path C investigation confirmed that typst.ts already exposes addSource + mapShadow + compile({ mainFilePath }) since v0.4.0-rc5, so if we ever wanted to push nodeToTypst INTO the Typst compile boundary, no fork is needed. Combined with the spike's negative result on standalone WASM and the finding that fused-WASM's perf gain is only ~30-50 µs/render, the decision is stay in TypeScript permanently for the render path. Only the vendored Typst WASM remains as WASM in the codebase.

TL;DR

No. WASM is 2-7× slower than the TypeScript implementation on every fixture, with a ~9-11 ms cold-load cost and ~24× larger ship size (brotli). Drift was the original justification for moving to Rust; that argument is gone now that pipeline stages are extracted in TS (cvl-node-tree-direct branch). On pure performance — the only remaining argument — WASM loses outright.

Outcome (slate) Verdict
5×+ speedup, smooth tooling Yes — worth pursuing one-function WASM ports
2-5× speedup Probably yes if build complexity is manageable
1-2× speedup Marginal — depends on whether build cost is amortized over many calls
Equal speed / slower No — drift was the main argument; stage extraction (TS) solved that

We hit the bottom row. Recommendation: do not pursue WASM for nodeToTypst and treat this spike as decisive against the broader pattern for any function of similar shape (string-to-string, sub-millisecond on TS already, JSON-shaped inputs).


1. Warm performance — WASM is slower across the board

All numbers in ms/call, warm (200 iterations after a discarded cold call), averaged across the loop, with p50 and p99. nodeToTypstUncached is used on the TS side so the LRU is bypassed and we measure compute. The WASM path goes through nodeToTypstWasmSync(wasm, ...) — module already loaded, two JSON.stringify calls + one WASM trip per iteration.

Run 1:

Fixture TS avg / p50 / p99 WASM avg / p50 / p99 Speedup
CV-en 0.313 / 0.240 / 1.575 0.855 / 0.818 / 1.756 0.37×
CV-de 0.329 / 0.262 / 1.088 0.808 / 0.775 / 1.427 0.41×
CL-en 0.047 / 0.038 / 0.327 0.274 / 0.264 / 0.454 0.17×
CL-de 0.037 / 0.034 / 0.112 0.269 / 0.253 / 1.185 0.14×

Run 2 (stability check):

Fixture TS avg / p50 / p99 WASM avg / p50 / p99 Speedup
CV-en 0.434 / 0.240 / 3.908 1.056 / 0.713 / 7.202 0.41×
CV-de 0.399 / 0.271 / 3.715 0.820 / 0.766 / 1.493 0.49×
CL-en 0.044 / 0.036 / 0.247 0.278 / 0.257 / 0.612 0.16×
CL-de 0.037 / 0.035 / 0.082 0.244 / 0.237 / 0.406 0.15×

(Speedup column = warmTsAvgMs / warmWasmAvgMs. Values <1 mean TS is faster. p50 values are stable across runs; p99s wobble with GC, noise from background processes, and JIT effects on the TS side — these are not optimized away here.)

The CL fixtures are particularly damning: TS finishes in ~40 µs, WASM needs ~270 µs. The serializer is a hot string-builder, JIT does an excellent job, and there is simply nothing for a compiled language to win against here once JSON-marshalling cost is in the picture.

Why WASM is slower

The native Rust microbench (cargo run --release --example native_bench, against the dumped CV-en fixture) decomposes the WASM cost cleanly:

native pure serializer:     66.7 us = 0.067 ms/call
native with JSON parse:    268.4 us = 0.268 ms/call
native JSON parse alone:   169.1 us = 0.169 ms/call
tree_json size: 40,934 bytes (40.0 KB)

So the pure Rust serializer is ~5× faster than the TS serializer (0.067 ms vs ~0.33 ms). But:

  1. serde_json::from_str on a 40 KB tree costs 169 µs by itself — 2.5× the pure compute. The WASM API can't skip this; it parses what JS gives it.
  2. The JS-side JSON.stringify on the tree adds another chunk on the call site.
  3. The WASM↔JS trip costs at least ~120 µs (see §4) regardless of payload.

In other words: TS sits in V8's resident object representation and walks it directly; WASM must reify the tree from JSON across the boundary. The boundary tax dominates a sub-millisecond compute.

The break-even input size where WASM compute would amortize parse + trip cost is around 10× the current tree. The CV/CL trees are not going to grow that much.

2. Cold instantiation

  • preloadWasm() (read .wasm, compile, instantiate via wasm-pack's nodejs glue): 9.83-10.75 ms in Node 22 / Bun 1.3.13 on a CachyOS desktop.
  • First-call nodeToTypstWasmSync after the module is loaded: 0.30-3.41 ms depending on fixture. This is dominated by per-fixture JIT effects, not WASM-specific cost.
  • Browser cold (not measured here): expected to be similar order — Chrome and Firefox both compile a 146 KB binary in single-digit ms on modern hardware. The full request budget would also include .wasm HTTP fetch time, which on Cloudflare's edge is sub-50 ms but is more than the cost of just-loading-more-JS.

Cold cost is dwarfed by the warm penalty for a CareerVector page that renders ≥1 CV. For a single render the cold load adds ~10 ms on top of the already-slower WASM path, making first-render latency strictly worse than TS by a factor we don't even need to compute precisely — it's a loss against a faster steady-state.

3. Bundle size

Raw vs gzip-9 vs brotli-11, on the post-wasm-opt build (149,498 bytes of cvl_core_spike_bg.wasm):

Asset Raw gzip -9 brotli -11
TS source (file, unminified, dependency-free) 18.3 KB 4.9 KB 4.2 KB
TS source (minified standalone, externalized deps) 7.3 KB 2.7 KB 2.4 KB
TS source (bundled with @cv/schemas etc., minified)¹ 75.5 KB 18.5 KB 16.5 KB
WASM binary 146.0 KB 66.0 KB 56.5 KB
WASM glue (.js) 3.7 KB 1.2 KB 1.1 KB
WASM total 149.7 KB 67.2 KB 57.6 KB

¹ The fair like-for-like is the standalone row (2.4 KB brotli). The bundled row exists because some of @cv/schemas' surface is reused elsewhere on the page, so the marginal cost of shipping the TS function as part of the existing app bundle is well below the brotli'd 16.5 KB — and dependencies don't show up as new bytes.

WASM ships ~24× more bytes over the wire (brotli) than the equivalent TS, and that is the binary alone — not counting that the Rust path doesn't replace @cv/schemas (zod runtime + types are still needed JS-side for input shaping).

4. Cross-boundary overhead

This is where WASM bleeds most. Same CV-en input, but tested two ways:

  • One big call: the whole tree, one WASM invocation.
  • Many small calls: one WASM invocation per top-level section (18 sections after stripping personal-info).

(Each path is pre-stringified outside the timing loop to isolate trampoline-and-WASM-compute from JSON.stringify.)

Mode WASM ms/render TS ms/render
One big call 0.46 / 0.54 0.25 / 0.25
Many small calls (18×) 2.57 / 2.74 0.33 / 0.31
Small/big penalty 5.05-5.58× 1.23-1.31×

A "trivial-input" probe (empty list root) gives a lower bound on a single WASM trip: ~120-124 µs/call, of which ~60 µs is the trampoline itself and the rest is serde_json parsing a ~30-byte input. This is the fundamental floor — every WASM call across our boundary pays at least this much.

TS scales gracefully because the JIT inlines the recursive walk and there is no per-call setup cost beyond a function frame; WASM scales poorly because every call eats the trampoline. If we ever did per-section rendering (e.g. for incremental layout, partial preview rebuilding), WASM would be 8-9× slower than TS, not 2-3×.

5. Build pipeline friction

Honest count, including the four already-landed commits this branch carries:

  • Cargo + wasm-pack toolchain bootstrap: ~1 hour. wasm-pack was packaged on this distro so install was painless. First cargo build --release --target wasm32-unknown-unknown succeeded once Cargo.toml had crate-type = ["cdylib", "rlib"] (the rlib is needed for cargo bench/cargo run --example, which is not the wasm-pack default).
  • serde_json::Value boundary: ~30 min. Original sketch used per-Kind structs; switching to Value::get and a kind: String discriminant on the Node struct unblocked the heterogeneous tree without writing 20 variant types.
  • Mirroring the TS branching logic precisely: ~2 hours. The serializer is ~500 lines of mutually-recursive helpers in TS. Each had to be reproduced with byte-identical output, against the smoke comparison tsOut.length === wasmOut.length + the explicit equality check (see Determinism below). Three small bugs caught this way:
    • escape_typst vs escapeTypst operator precedence on bracket escapes
    • Off-by-one when no list separator is configured
    • bullet_v_align resolving differently when null vs missing
  • wasm-pack output juggling: ~30 min. The bench expects the nodejs target; the web app would want the web target. build.sh was added to write both to known paths so the test could import one and the (eventual) browser path the other. wasm-pack does not let you produce both in one invocation, so this is two compiles per change.
  • Vitest + dynamic-import path: ~20 min. The wasm-pack glue uses CJS-style require() of the .wasm file; vitest's bundler had to be steered around it via the /* @vite-ignore */ annotation in node-to-typst-wasm.ts. A bare import() of an absolute path "just works" under Bun, but the relative form needs the comment.

Subtotal: ~4-5 hours of build/tooling friction for a 500-line function port. That is not catastrophic but it is meaningfully higher than the friction-floor of "edit TS, save, watch test pass." Every iteration in development pays both a wasm-pack build (5-6 seconds in the warm case) and the wasm-opt step (3-4 seconds), which means each red-green cycle is ~10 s slower than the equivalent TS edit. For a hot-loop component this is a real productivity tax.

6. Debugging viability

Procedure: inserted panic!("SPIKE_PROBE: deliberate panic to measure JS stack trace") at the top of node_to_typst_impl, rebuilt with bash build.sh, called the WASM path from a Bun script, and captured the thrown error.

Result:

name: RuntimeError
message: Unreachable code should not be executed (evaluating 'wasm.node_to_typst(ptr0, len0, ptr1, len1)')
stack:
RuntimeError: Unreachable code should not be executed (evaluating 'wasm.node_to_typst(ptr0, len0, ptr1, len1)')
    at unknown
    at unknown
    at unknown
    at node_to_typst (cvl_core_spike.js:18:26)
    at async main (panic-probe.ts:7:27)

Observations:

  • The panic message string ("SPIKE_PROBE: ...") is gone. This is because the release profile is configured with panic = "abort" for size — it compiles every panic site down to unreachable, and the JS host only knows it hit a trap.
  • Three frames inside the WASM module are reported as at unknown. WebAssembly is shipped without DWARF source maps by default, so symbol resolution stops at the module boundary. We can fix this with a debug build (DWARF) plus a browser DevTools that supports it (Chrome does, Firefox partly), but DWARF inflates the binary 2-3× and would still not help in production-error reporting (Sentry et al. do not symbolicate WASM).
  • We could swap panic = "abort" for panic = "unwind" and pull in console_error_panic_hook to forward panics as JS errors with full messages, at the cost of ~10 KB extra binary and only marginally better stack quality (function names appear if symbol stripping is off; line numbers do not).

Verdict: debugging WASM is decisively worse than TS for our purposes — particularly given Sentry will receive RuntimeError: Unreachable for any non-trivial server-side panic. We'd be debugging production errors against at unknown frames.

7. Determinism

  • Same fixture, two consecutive nodeToTypstWasmSync calls: byte-identical output (det1 === det2 → true).
  • TS vs WASM output (equality-check.ts): byte-identical for all four fixtures.
CV-en: byte-identical (17676 chars)
CV-de: byte-identical (19079 chars)
CL-en: byte-identical (4942 chars)
CL-de: byte-identical (5318 chars)

The serializer is pure (modulo HashMap iteration order — none used). This is the one piece of unambiguous good news: if we did adopt WASM we would not be introducing a divergence between paths. But since we don't run two paths in production, this property buys us nothing.

8. TypeScript baseline still passes

bun --filter '@cv/domain' test typst-bench runs unchanged (4 tests passed, ~75 ms). No regressions in the pre-existing perf test suite. The WASM-related code is entirely additive — node-to-typst.ts continues to be the single production path and the spike crate is unreferenced outside the bench.

9. Scope discipline

This spike intentionally ports only the hot path: preamble + section walk + list/bullet/sub/subheading emission, plus the variant-pool resolver. The convention-resolved Nodes ({closing}, {opening}, {subject}), the hero block, the explicit pageBreak/space slots, and link styling are not ported — these are pre-resolved by prepareTreeForRender on the TS side. Output parity is therefore measured on real inputs the production renderer hands the serializer, not on synthetic minimal trees.

A full-coverage port would roughly double the Rust surface (estimate from comparing TS pre-resolve helpers to ported ones), introduce additional dependencies on Y.js peer surface (the convention resolver reads from the doc), and not change the bench result — those branches don't run in the hot path. There is no scenario where finishing the port flips the verdict.

10. Recommendation

Discard the WASM port. Land the spike branch as an archival artifact, but do not adopt the WASM path in production.

Reasons (recap):

  • Performance: TS is 2-7× faster. The boundary tax (serde_json on 40 KB + WASM trampoline) is structurally larger than the compute savings.
  • Drift solved elsewhere: the cvl-node-tree-direct branch's pipeline-stage extraction in TS eliminates the original argument for porting to a single source of truth.
  • Ship size: 24× over-the-wire (brotli) for a function that has no other reason to grow.
  • Build complexity: 4-5 hours of friction to set up; ~10 s slower red-green cycle per iteration thereafter.
  • Debugging: generic "unreachable" traps with no source mapping in production error pipelines.
  • Code obfuscation argument: real but minor. The CareerVector secret sauce is in the model/data layer, not the serializer; obscuring nodeToTypst is not worth the cost.

The honest counterfactual is: for what target speedup would we revisit? If a future port were on a function where TS warm avg is ≥5 ms and the input is small (say a numeric kernel like a scoring matrix multiplication, where the JSON-marshalling tax disappears), WASM might pay off — but those functions don't exist in the current codebase. nodeToTypst was the best candidate we have, and it failed.

Reproducing

From a fresh checkout of branch wasm-spike:

cd experiments/cvl-engines/core-spike
bash build.sh                                            # rebuilds pkg-node + web
cd ../..
bun --filter '@cv/domain' test typst-wasm-bench          # WASM-vs-TS perf + sizes
bun --filter '@cv/domain' test typst-bench               # TS baseline (unchanged)
cd experiments/cvl-engines/core-spike
cargo run --release --example native_bench               # native Rust microbench

Fixture dump (used by native_bench):

cd lib/domain
cat > dump-fixtures.ts <<EOF
import { DEFAULT_CV_PROFILE_MAP, DEFAULT_FORMAT_SETTINGS } from './src/defaults';
import { writeFileSync } from 'node:fs';
writeFileSync('/tmp/cv-en-tree.json', JSON.stringify(DEFAULT_CV_PROFILE_MAP.en.tree));
writeFileSync('/tmp/cv-en-format.json', JSON.stringify(DEFAULT_FORMAT_SETTINGS));
EOF
bun run dump-fixtures.ts && rm dump-fixtures.ts

Files

  • experiments/cvl-engines/core-spike/ — Rust crate: lib.rs, node.rs, typst.rs, build.sh, examples/native_bench.rs
  • experiments/cvl-engines/core-spike/pkg-node/ — wasm-pack nodejs build output (149 KB binary)
  • web/src/lib/wasm/cvl-core/ — wasm-pack web build output (same binary, ESM glue)
  • lib/domain/src/node-to-typst-wasm.ts — JS wrapper around the WASM module
  • lib/domain/tests/typst-wasm-bench.test.ts — the benchmark used in this report
Source: wiki/content/investigations/wasm-spike-2026-05-11.md