-
Notifications
You must be signed in to change notification settings - Fork 48
GraalVM WASM research
This page covers compiling apex-ast-serializer (jorje included) to WASM with
GraalVM. It splits off from WASM research, where GraalVM was
originally listed as "not pursued".
Status as of 2026-08-26, measured against Oracle GraalVM 25.0.4 (25.2.4+7.1):
it works. The parser compiles with zero reachability config and produces a
byte-identical AST. It is not shippable yet, but the reasons are product decisions
and missing plumbing rather than fundamental limits, which is a different situation
from Bytecoder, J2CL and TeaVM.
- Release notes, the only mention it has ever had: https://www.graalvm.org/release-notes/25.1/
- Tracking issue, open since 2021: https://github.com/oracle/graal/issues/3391
- Official demos, including
export-java-functionandjs-java-object-exchange: https://github.com/graalvm/graalvm-demos/tree/master/web-image - Suite open-sourced into oracle/graal: https://github.com/oracle/graal/pull/11021
- Binaryen, needed for
wasm-as: https://github.com/WebAssembly/binaryen - Wasm I/O 2025 slides: https://2025.wasm.io/slides/the-future-of-write-once-run-anywhere-from-java-to-webassembly-wasmio25.pdf
- Jfokus 2026 slides: https://www.jfokus.se/jfokus26-preso/Graalvm-Wasm-Fast-Efficient-Portable-Apps.pdf
- Wingo on pre-initializing GC heaps, still the state of the art on paper: https://wingolog.org/archives/2023/03/10/pre-initialization-of-garbage-collected-webassembly-heaps
- Node persistent WASM cache, open since 2020: https://github.com/nodejs/node/issues/36671
The product is called Web Image, not "GraalVM WASM". It is a Native Image backend
enabled with --tool:svm-wasm that emits a .wasm module plus a JavaScript wrapper.
It is much newer than the 2021 issue suggests. Web Image has appeared in exactly one GraalVM release note, 25.1 on 2026-06-30, and not in 25.2 or 25.3 since. There is no changelog anywhere, and the 25.1 announcement blog post does not mention it. Status is experimental.
It is Oracle GraalVM only. CE 25.1.3 has no lib/svm/tools/ directory at all. The
org.graalvm.webimage.api annotations ship in CE, the builder does not. This alone
blocks CI on CE.
# Binaryen >= 119 must be on PATH, wasm-as is used as the assembler
native-image --tool:svm-wasm \
-cp <jorje + commons-cli + commons-io + jackson-core + parser> \
net.dangmai.serializer.Apex apex-wasmBuilds in 52 to 77 seconds. Output is 10 to 12 MiB of .wasm (3.9 MB gzipped) plus
about 107 KiB of JS glue. Two independent attempts both succeeded with no
reachability config, no --initialize-at-build-time and no substitutions. The AST is
byte-identical to the JVM and to the shipped native binary, verified by SHA-256 over
125 fixtures.
--tool:svm-wasm appears in neither --help nor --help-extra.
The blockers that killed the other three approaches do not apply. Web Image consumes
bytecode and reuses the full Native Image closed-world machinery, so JDK coverage,
java.time, java.text and Guava are non-issues by construction.
getPlatformClassLoader, which killed TeaVM, does not occur anywhere in the jorje
jar.
Measured with -Xlog:class+load across four fixtures, anonymous mode and the parse
error path: 769 non-JDK classes load, out of 4,939 in the jar. Guice (600 classes),
ASM (142), hamcrest (102), apex-system.db (6.3 MB) and the 2,345
StandardApexLibrary stubs are provably unreferenced. About 8.5 MB of the 9.7 MB jar
is inert.
The complete native-image agent metadata for a one-shot parse is 15 entries, and none
is an apex.jorje.* type:
- Guava
AbstractFuturegrabbingUnsafe.theUnsafeplus five field offsets - JDK CLDR locale and timezone data, pulled in by SOQL date literals
- the
messagesResourceBundle, on the parse error path only
A probe comparing Thread.getAllStackTraces() around Apex.getAST shows 6 threads
before and 6 after, all JVM built-ins. jorje spawns nothing. There is no
ServiceLoader usage anywhere in the jar, no dynamic proxies, no defineClass, no
Class.forName on the parse path, no file I/O, no sockets and no System.exit.
Side note: .claude/rules/java-serializer.md says the agent metadata is still needed
"for proxies/resources". There are zero proxies.
All numbers on a Ryzen 7 5700X, Node 24.19.0, Oracle GraalVM 25.0.4.
These numbers went through two bad measurements before being reconciled, so what follows is the settled version. See "How the first two runs went wrong" below if you want the details, because the failure modes are worth not repeating.
Steady-state per-file real parse plus serialize, measured in-process with no JS boundary crossing, identical Java benchmark loop compiled three ways, min of 9 timed passes after warm-up:
| input | JVM | native | wasm |
|---|---|---|---|
| 176-file corpus (227 KB) | 32.1 ms | 48.0 ms | 65.2 ms |
PerfBenchmarkLarge.cls (126 KB) |
21.8 ms | 28.7 ms | 49.4 ms |
wasm is 2.0 to 2.5x slower than a warm JVM and 1.4 to 1.7x slower than the native binary. It is never at parity. Min-of-9 is stable to about +/-5-10%. Medians are not usable here: GC pauses inflate them by 7% (wasm, large input) up to 137% (native, large input).
Two things fall out of this that matter beyond the wasm question:
- A warm JVM beats our own native binary, substantially (21.8 vs 28.7 ms on the large file, 32.1 vs 48.0 on the corpus). C2 with runtime profiles wins against AOT once it is warm, and the native binary's SerialGC is the outlier: 4.8 s of pauses over ~212 parses, which is why its median runs 2.1x its min.
- The shipped
apex-ast-serializer-linux-x64v2.3.0 binary emits a different, larger AST than the current source tree (4,483,744 vs 4,291,809 chars). That is version skew, not a runtime difference, but it means any correctness check against that binary is invalid.
The obvious hypothesis was that WasmGC degrades on allocation-heavy work, so a
126 KB file building a 4.3 MB AST should be disproportionately bad. It is not.
Across a 112x size range (inputs built by truncating PerfBenchmarkLarge.cls at
method boundaries):
| src bytes | AST chars | JVM | native | wasm | w/JVM | w/native |
|---|---|---|---|---|---|---|
| 1,128 | 33,833 | 0.387 | 0.305 | 0.576 | 1.49 | 1.89 |
| 4,980 | 164,202 | 0.855 | 1.066 | 1.854 | 2.17 | 1.74 |
| 20,490 | 689,281 | 3.247 | 4.474 | 7.512 | 2.31 | 1.68 |
| 61,305 | 2,074,168 | 9.905 | 13.923 | 23.337 | 2.36 | 1.68 |
| 126,662 | 4,292,040 | 21.568 | 36.355 | 54.448 | 2.52 | 1.50 |
Against the native binary, which shares the same compiler front-end and differs only in backend, the penalty is flat at 1.5 to 2.0x with no upward trend. The wasm/JVM ratio does climb (1.49 to 2.52), but that is the JVM's JIT pulling ahead with size, not wasm degrading: native/JVM goes 0.79 to 1.69 over the same sweep.
GC evidence agrees. wasm allocates about 1.85x the JVM's bytes at every size (ratios 2.18, 1.52, 1.97, 1.81, 1.87 across the sweep) and spends a comparable share of wall time in GC (~10% vs ~9.5%). It is a constant per-byte overhead from wider object layout, not a size-dependent collapse.
Web Image's pipeline is deliberately reduced: MidTier runs 3 phases against standard
Graal's ~30, createLIRSuites() returns null so there is no register allocation,
and partial escape analysis is off by default. Enabling -H:+UsePEA measured as
noise. Web Image also does not support deoptimization, so speculative optimization is
off the table in principle, which is most of what C2 does to beat AOT. Independent
work (Wingo's wastrel, February 2026) puts V8's WasmGC roughly 3x off a purpose-built
GC.
Recording this because both failure modes are easy to repeat.
Run 1 measured an under-warmed JVM. The harness ran 200 iterations with no separate warm-up and reported a best-10 average over that same ramp, giving the JVM 10.7-16.0 ms on the large file. With 200 iterations discarded and then measured, the warm JVM does it in 5.80 ms parse-only. The native and wasm figures from that run reproduced almost exactly; only the JVM number moved.
Run 2 compared against an estimate, and measured different work. Two separate errors compounding:
- The JVM baseline was estimated by subtracting 178 x a 1.774 ms HTTP round-trip floor from a ~420 ms corpus pass. That removes 85% of the measurement, and it treats the HTTP overhead as constant when it is size-proportional (Jetty and Jersey deserialize the request and write an AST body up to 415 KB, none of which a 15-byte probe captures). The measured in-process JVM figure for that exact workload is 22.07 ms, against the ~76 ms estimate, so the estimate was 3.3x too high.
- More importantly, the two harnesses were not measuring the same thing. See the next section.
This gap is not closing soon. Web Image's pipeline is deliberately reduced: MidTier
runs 3 phases against standard Graal's ~30, createLIRSuites() returns null so
there is no register allocation, and partial escape analysis is off by default.
Enabling -H:+UsePEA measured as noise. Independent work (Wingo's wastrel, February
2026) puts V8's WasmGC roughly 3x off a purpose-built GC.
Cold process, one file: 487 ms for wasm against 36 ms for the native binary. Node's own floor is 34 ms.
The dominant cost is WebAssembly.instantiate, not compilation. A native binary's
image heap is a byte-for-byte snapshot in the ELF data section that the OS mmaps at
near-zero cost. WasmGC objects are engine-managed structs and the module format has
no way to express a serialized GC object graph, so GraalVM emits the image heap as
executable code: a start function running struct.new for each of about 123,000
objects, on every instantiation. From WasmGCHeapWriter.java:
Due to WasmGC having its own object model, we cannot directly write objects into raw memory, the objects need to be created during startup.
Instantiate tracks object count almost exactly linearly at ~1.35 microseconds per object. No build flag reduces the count.
This is not fixable by waiting:
- No Wasm proposal exists for GC heap snapshotting or serialization. Not stalled, never started. The GC Post-MVP doc lists 18 future features and mentions none of this. CG meeting notes contain zero mentions of "image heap" or "GraalVM", ever.
- V8 cannot snapshot WASM.
src/snapshot/snapshot.cchas a hardFATAL("Exported WebAssembly functions are not supported in snapshots"). - Node has no persistent WASM compilation cache.
module.enableCompileCache()covers CJS, ESM and TypeScript only. - Even a perfect code cache would not help, because it caches machine code, not the constructed heap.
| change | effect |
|---|---|
worker_threads Worker |
main thread free after 3 ms, everything else overlaps |
| linear-memory backend | instantiate 160 ms to 3 ms |
wasm-opt -O3 |
first parse -47%, next 124 parses -62%, whole run -25% |
-H:ImageHeapObjectsPerFunction=4000 |
instantiate -18%, does not compose with -O3 |
-H:+UsePEA |
nothing |
The Worker result matters most. WebAssembly.compile is already off-thread, but
instantiate is additive with main-thread work (a 300 ms busy loop plus 196 ms
instantiate measured 476 ms). In a Worker, wall time becomes max rather than sum, and
the 4.29 MB AST comes back as a plain string in 1.5 ms. Prettier's parser hook
accepts a Promise, so this fits without changing the plugin's contract.
wasm-opt is never run by Web Image at all, Binaryen is used only as an assembler.
The entire -O3 win is in the Liftoff warm-up region, which is where a per-invocation
prettier run lives, so it matters in practice even though fully-warm steady state
does not move.
GraalVM has a second Web Image backend targeting linear memory with a bundled
mark-sweep GC. It writes the image heap through the same NativeImageHeapWriter the
ELF backend uses, emitted as active data segments, so instantiation is a
memory.init rather than 123k constructions.
It is gated off from native-image by two things, both bypassable without touching
the GraalVM install:
mkdir -p optcfg/tools/wasmlm
printf 'ExcludeFromAll = true\nProvidedHostedOptions = Backend=\n' \
> optcfg/tools/wasmlm/native-image.properties
native-image --tool:svm-wasm --configurations-path ./optcfg --tool:wasmlm \
-J-Dcom.oracle.graalvm.iswebimage=true -H:Backend=WASM -cp ... Drv -o lmThe first unlocks -H:Backend, which is absent from ProvidedHostedOptions in
native-image.properties. The second flips isNativeImageBackend(), which otherwise
forces WASMGC in WebImagePlatformInjector. The result is a genuine linear-memory
module with a real memory section, and the AST digest matches.
| wasm | compile | instantiate | 1st parse | 124 more | total | |
|---|---|---|---|---|---|---|
| WasmGC | 11.90 MB | 37.1 | 160.0 | 109.6 | 180.7 | 557.2 |
| LM | 18.68 MB | 14.7 | 3.0 | 162.7 | 395.1 | 639.8 |
| LM + -O3 | 15.77 MB | 16.4 | 4.6 | 77.4 | 143.1 | 311.3 |
LM plus wasm-opt -O3 is the fastest cold configuration by a wide margin: compile
plus instantiate is 21 ms against 197 ms. The trade is a fully-warm steady state 3.3x
slower and a module 1.3 to 1.6x bigger. LM suits short-lived CLI runs, WasmGC suits a
long-lived process.
Oracle has never publicly discussed this backend, in either direction. It is
maintained in lockstep with repo-wide refactors but has had no feature commits,
WebImageWasmLMBackend.java has one commit ever. Getting Oracle to expose
-H:Backend=WASM is the single highest-leverage thing anyone could do here.
One serious catch found later: the LM build silently drops all @JS method
bodies. The globalThis.__apexParse export glue is present in the WasmGC build
and simply absent from the LM one, with no warning, no error, and exit code 0. So
LM's only input channel is argv, and it cannot host a JS-driven service at all.
LM and the interop/daemon path are currently incompatible, which takes a lot of
the shine off the 3 ms instantiate.
There is no IPC problem to solve here. The module runs inside the Node process, so
a parse is just a call to an exported function with a string argument. Sockets and
stdin only matter if we try to make the module behave like a standalone CLI. One
instance can serve unlimited parses for the life of the process, which covers both
the IDE case (a long-lived editor process) and the CI case (one prettier --check
run over many files).
ParserEngine holds two guava LoadingCaches (maximumSize(1000),
expireAfterAccess(1, MINUTES)) keyed on the source body. They hit, and hard: with
the cache warm, parse-only on the 126 KB file takes 0.004 ms on the JVM and
0.001 ms on wasm.
This is what broke run 2. Run 1's harness appended "\n// iter " + i on every
iteration, defeating the cache, so it measured parsing. Run 2 replayed the same 178
fixture strings, so every call after the first was a cache hit plus serialization.
The two runs were measuring different work, which is the larger half of why they
disagreed.
An earlier probe (parse A five times, then unseen B, then A again) showed no drop on repeat and was read as "the cache never hits". That reading was wrong. The probe timed the full path including marshalling at ~60 ms per file, which swamps a sub-millisecond parse saving entirely.
For our workload the cache is still not a lever, but for a different reason than first thought: Prettier hands the parser a distinct file every time, so in production the hit rate is zero. That makes the cache-defeated numbers the correct model for real use, and it means any future benchmark here must defeat the cache deliberately or it is measuring serialization only.
Eight passes over the same ordered corpus: 1267 ms, 775, 748, ... 742. About 525 ms of one-time excess, roughly 95% of it consumed in the first pass.
It is not JIT tiering. With --liftoff-only the warm-up curve keeps the same shape
(530 ms of excess) and tier-up is worth only ~13% on steady-state throughput.
--no-liftoff is catastrophic: 35 s to TurboFan the 10 MB module and 3.9 GB RSS.
The clean decomposition is to instantiate a second instance from the
already-compiled WebAssembly.Module in the same process, so V8's code cache is
warm but the Java heap is fresh:
| compile | instantiate | pass1 | pass2 | |
|---|---|---|---|---|
| instance 1 | 32.6 | 135.7 | 1282.6 | 804.9 |
| instance 2 | 0 | 32.2 | 837.0 | 743.5 |
So of ~508 ms of warm-up, about 414 ms (81%) is V8's lazy baseline compilation of
the 10 MB module, and about 94 ms (19%) is Java-level one-time init (class init,
the ~13 MB ApexLexer.DFA23_transition dedupe, ANTLR DFA, Guava setup).
Instantiate also drops from 135.7 ms to 32.2 ms once the module is compiled.
712 parses through one instance in an adversarial order, interleaving a parse-error file before every valid file and then sweeping in reverse, with digests compared byte-exact against a fresh instance per file: 173/173 identical, no drift, no cross-file state leakage.
Memory plateaus. Over 6000 parses (170 MB of AST produced) RSS shows a clear sawtooth with GC drops, settling at roughly 480 to 550 MB, with a residual slope of 0.9 MB per 1000 parses, which is inside sawtooth noise. The JVM server sits at 325 MB for comparison.
Worth recording as a tried-and-failed idea. Wizer pre-initializes a wasm module by running an init function and snapshotting linear memory into data segments, which should in principle bake a warmed-up parser into the LM build. It rejects our module outright, on both v9 and current v11.0.3:
Error: exceptions proposal not enabled (at offset 0x3add)
Reference types were never the constraint. The LM module uses the wasm
exception-handling proposal, jorje signals parse errors through Java exceptions, and
GraalVM exposes no way to build without wasm EH. Two further blockers behind that:
the exported main takes 3 params where Wizer needs 0, and the module imports 15
non-WASI functions including Date.now.
Even if it worked it would not be worth much. Per the decomposition above, Wizer can only snapshot Java heap state, which is the 94 ms slice. Its theoretical ceiling here is under 20% of the warm-up.
This was the largest and least expected finding.
Getting the AST string out of the module is a per-character copy through a JS Proxy. The generated glue does:
function charArrayToString(arr) {
let res = []; const len = 512;
for (let i = 0; i < arr.length; i += len)
res.push(String.fromCharCode(...arr.slice(i, i + len)));
return res.join("");
}arr is a Proxy, so slice fires 512 get traps, each calling a Wasm export. Two
dispatches per character. Measured at 174 ns/char, 5.47 MB/s, which is 748 ms for a
4.29 MB AST.
There are no JS String Builtins in this backend, confirmed from the import sections of the generated modules, from grepping the builder jars, and from the docs, which enumerate GC, Exception Handling and Typed Function References only. There is no linear memory in WasmGC modules, so there is no bulk transfer path.
For contrast, JSON.parse on the same 4,291,809-char file is 13.26 ms at 308 MB/s.
V8 was never the bottleneck.
Measured by building a second image exporting parseLen, which does identical work
but returns only the string length, so the marshalling is excluded. Over all 178
fixtures:
full: 737.5 ms lenOnly: 55.2 ms marshalling: 682.3 ms (92.5%)
JSString.of() pushing 5 MB of UTF-16 across the boundary costs 682 ms, at about
7.4 MB/s. Per-file time correlates with AST output bytes at r = 0.9997, against
0.983 for source bytes.
Note the 55.2 ms figure had jorje's parse cache warm (see above), so it is really cache-hit plus serialize. Substituting the honest cache-defeated number for the same corpus, 65.2 ms, the marshalling share is 91% rather than 92.5%. The conclusion survives the correction unchanged: the boundary is roughly an order of magnitude more expensive than the parsing.
The parser was never the problem. The boundary is.
Extrapolated to the real AST, 63,457 nodes and 4,291,809 chars:
| design | estimated |
|---|---|
| JSON with stock glue (what a naive port gives) | ~780 ms |
| JSON with a proxy-bypass glue patch | ~130 ms |
| direct interop construction with string interning | ~50 ms |
The glue patch reads the WasmGC array directly instead of through the Proxy, same algorithm otherwise. It measured 21.4 ns/char against 174, a 6.4x win, and it is a mechanical edit to a generated file. Worth doing whichever path we choose. The residual 21 ns/char is the per-character export call, which needs a bulk export to remove.
Building the JS object graph node by node from Java, rather than serializing, works in 25.0.4 and is about 15x faster than the current design. Measured on a 60,000-node synthetic tree, min of 6 runs:
| variant | per node | vs JSON path |
|---|---|---|
| nothing interned | 17,496 ns | 1.0x |
keys and @class interned |
4,492 ns | 4.0x |
| full string intern map (realistic) | 823 ns | 21.9x |
| constants only (floor) | 603 ns | 29.9x |
Interning is the whole result. Passing an existing JS value across costs about 14 ns,
creating one costs about 2 microseconds (JSString.of 2026 ns, JSNumber.of 2199
ns). Break-even is roughly 0.67 fresh strings per node. The real AST has 500 distinct
string values and 76 distinct keys across 63,457 nodes, about 0.008 fresh strings per
node, which is 80x inside the margin.
There is no crossover in tree size. Both paths are linear and the ratio is flat from 2k to 150k nodes.
The result is real plain JS objects, not opaque handles. JSON.stringify of the
interop graph matches Jackson's output byte length exactly, node counts match, and a
full property walk gives an identical checksum at 11.1 ns/node against 8.5 ns/node
for a JSON.parse graph. That 30% read penalty is 0.2 ms across the whole AST.
This fits our architecture unusually well. The code-generated serializer already
writes through the sink/AstSink interface with JsonAstSink as one implementation,
so a JsAstSink is a sibling implementation driven by the same generator, not a
rewrite.
-
set(String, String)and plainintparameters silently produce broken objects. The value is present but invisible toJSON.stringify, andString(proxy)still prints correctly, so it fails late. Everything must beJSString.oforJSNumber.of. - A Java
intused as an array index silently does nothing. Usepush. -
@JS.Coercefixes the correctness problem but costs 11,142 ns/node against 231. Not usable on the hot path. -
@JS.Exportis inert in this backend. It targetsElementType.TYPE, and applied to aJSObjectsubclass the instance arrives in JS as an opaque proxy. The API guide says it is not implemented, the Javadoc says it works. Trust the guide. Both official demos use theglobalThishelper pattern instead, which does work. - User-defined functional interfaces are rejected at the boundary. Only JDK ones get
SAM metadata emitted, so use
java.util.function.Function. - One-shot construction in a single
@JSbody beats per-fieldsetby about 1.3x. -
-H:-AutoRunVMis required for the JS-drives-Java pattern, and JS mustawait GraalVM.run([])before the exported function exists.
If a Node process holds a warm instance and serves parses over a local socket, Node does all the I/O and the wasm module needs none. Measured against the repo's JVM HTTP server, length-prefixed binary protocol over a unix domain socket:
| wasm daemon (UDS) | JVM HTTP server | |
|---|---|---|
| cold start to first response | 494 ms | 1151 ms |
| median request | 0.59 ms | 2.03 ms |
| p90 / p99 | 13.6 / 69.2 ms | 2.74 / 5.20 ms |
| steady corpus pass | 843 ms | ~420 ms |
| throughput | 208/s | 454/s |
| RSS | ~500 MB | 325 MB |
Cold start is 2.3x better and median latency 3.4x better, but the tail blows out,
and that tail is entirely the marshalling on large-AST files. Build-time warm parses
do not help, they just move the cost before listen.
If the boundary were fixed (Java writes UTF-8 into a buffer JS reads as a typed array, rather than returning a JS string), the daemon would land at roughly 55 ms plus socket overhead per corpus pass, which would be about 2x faster than the JVM HTTP server on top of the better cold start. That is an estimate grounded in the measured decomposition above, not a measured result.
Note this shape is only needed to bridge separate short-lived Node processes. An IDE
keeps one long-lived process, and prettier --check . is one process over many
files, so both already get warm reuse from a plain in-process instance. The daemon
only earns its keep for repeated separate CLI invocations, such as a per-commit hook.
Measured end to end from inside a running Node process, Node's own startup excluded
from all three. (a) is WasmGC plus -O3 with the AST actually landing in JS, (b) is
the shipped native binary spawned per file as parseTextWithSpawn does today, (c) is
a warm JVM HTTP server.
| N files | (a) wasm | (b) native spawn | (c) warm server |
|---|---|---|---|
| 1 | 251 | 50 | 2 |
| 10 | 299 | 462 | ~25 |
| 25 | 351 | 1,141 | 58 |
| 125 | 578 | 5,598 | 301 |
Fits: (a) is about 240 + 2.7N, (b) about 45N, (c) about 2.4N.
Against the default native mode the crossover is around 6 files, and by 125 files
wasm is 9.7x faster. With LM plus -O3 the crossover moves to about 3.
Against a warm server wasm never wins on marginal cost, 2.7 ms/file against 2.4. But the server costs 1,328 ms to first response and its first 20 requests are slow, so wasm beats a cold-started server for any batch under roughly 5,000 files.
- Oracle GraalVM only. CE has no builder, so CI would need Oracle GraalVM.
-
System.inis a hard stub,VMError.unimplemented, confirmed by javap. Our entire CLI contract is stdin. - The filesystem is an in-memory jimfs with no host bridge.
Files.readAllByteson a real path throwsNoSuchFileException. - Not WASI. Zero
wasi_snapshot_preview1imports, so wasmtime cannot run it. It only runs inside a JS host, and the glue is mandatory. - 53 of 178 fixtures fail with
MissingResourceRegistrationErrorfor themessagesbundle, which is every file with a parse error. One flag fixes it (-H:IncludeResourceBundles), but it must not be missed, sinceparser.tssurfaces that message text to users. - Reaching the LM backend needs two undocumented steps that could break at any release.
-
Thread.start()is substituted to a silent no-op. Harmless here since jorje spawns nothing, but it says something about maturity. - SOQL date literals need tzdb, which needs
java.util.zip, and that fails withUnsatisfiedLinkError: Can't load library: zipfollowed byNoClassDefFoundError: sun.util.calendar.ZoneInfoFile$Checksum. Two fixtures affected. This one has no known workaround yet. - Stack traces are raw
wasm-function[15242]:0x4eceadwith no symbols.
The case for this is portability, not speed: one artifact instead of a per-OS binary matrix, and every platform Node runs on supported out of the box. 10 MB of wasm against 23 to 35 MB per platform binary is also a large npm footprint win.
The performance case is no longer a blocker against the default native mode, which
pays a full process spawn per file. It remains a real loss against the built-in
HTTP server, which is already the fast path.
The lowest-risk way in is a fallback tier: keep native binaries for the platforms we build for, and ship wasm as the universal fallback for everything else. That delivers the portability goal with no regression on the common path, and it buys real production data before deciding whether it ever becomes the default.
Two things worth doing regardless of any of this:
- Intern the
@classFQCNs. Every node carries a fully-qualified name, which is why the JSON runs 21 to 34x source size. ADR 0001 already flags this as deferred, and it makes the current architecture faster too. - Drop the inert 8.5 MB from the jorje jar, or confirm the minimizer can.
- Resolved, kept as a note: an earlier version of this page reported wasm at parity with the JVM. That was an artifact of two compounding measurement errors, now corrected above. wasm is 2.0-2.5x a warm JVM and 1.4-1.7x the native binary. The file-size-dependence hypothesis was tested and rejected.
- Will Oracle expose
-H:Backend=WASM? Worth asking on #3391, which is still open with no linked PRs. - Does the playground make more sense as the first target? 2.9 MB brotli with roughly 260 ms to load and first parse is a plausible replacement for its server round trip, and every constraint that disqualifies wasm for the CLI (no stdin, jimfs, JS glue mandatory) is irrelevant in a browser.
- How much of the 4.3 MB AST survives FQCN interning? That changes both the serialization numbers above and the current architecture.
- Does a
JsAstSinkactually hit the 823 ns/node the synthetic benchmark suggests on the real AST shape? - Safari support for the exception-handling proposal is unconfirmed. webassembly.org says 18.4+ works, at least one live Web Image demo says it does not.
Everything was measured with Oracle GraalVM 25.0.4
(mise install java@oracle-graalvm-25.0.4) and Binaryen 132 on PATH. The
experiments were: a hello-world and Jackson feasibility pass, a full parser build
with an AST digest check across 125 fixtures, a startup-reduction sweep across build
configurations, and a 60,000-node interop microbenchmark against a real 4.29 MB AST
fixture.