Skip to content

GraalVM WASM research

Dang Mai edited this page Aug 27, 2026 · 3 revisions

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.

Links

What it is

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.

Building it

# 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-wasm

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

Why jorje was never the problem

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 AbstractFuture grabbing Unsafe.theUnsafe plus five field offsets
  • JDK CLDR locale and timezone data, pulled in by SOQL date literals
  • the messages ResourceBundle, 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.

Performance

All numbers on a Ryzen 7 5700X, Node 24.19.0, Oracle GraalVM 25.0.4.

Steady state

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-x64 v2.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.

No WasmGC size penalty

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.

Why it is slower

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.

How the first two runs went wrong

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.

Startup, and why it is structural

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.cc has a hard FATAL("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.

What actually reduces it

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.

The linear-memory backend

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 lm

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

Reusing a warm instance

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

jorje's caches do hit, and that wrecked one benchmark

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.

What actually warms up

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.

Reuse is correct and bounded

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.

Wizer does not work

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.

The serialization boundary

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.

How much of the total this actually is

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.

Three designs

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.

Direct interop

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.

Interop gotchas

  1. set(String, String) and plain int parameters silently produce broken objects. The value is present but invisible to JSON.stringify, and String(proxy) still prints correctly, so it fails late. Everything must be JSString.of or JSNumber.of.
  2. A Java int used as an array index silently does nothing. Use push.
  3. @JS.Coerce fixes the correctness problem but costs 11,142 ns/node against 231. Not usable on the hot path.
  4. @JS.Export is inert in this backend. It targets ElementType.TYPE, and applied to a JSObject subclass 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 the globalThis helper pattern instead, which does work.
  5. User-defined functional interfaces are rejected at the boundary. Only JDK ones get SAM metadata emitted, so use java.util.function.Function.
  6. One-shot construction in a single @JS body beats per-field set by about 1.3x.
  7. -H:-AutoRunVM is required for the JS-drives-Java pattern, and JS must await GraalVM.run([]) before the exported function exists.

The daemon shape

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.

Where it wins and loses

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.

What still blocks shipping

  • Oracle GraalVM only. CE has no builder, so CI would need Oracle GraalVM.
  • System.in is 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.readAllBytes on a real path throws NoSuchFileException.
  • Not WASI. Zero wasi_snapshot_preview1 imports, so wasmtime cannot run it. It only runs inside a JS host, and the glue is mandatory.
  • 53 of 178 fixtures fail with MissingResourceRegistrationError for the messages bundle, which is every file with a parse error. One flag fixes it (-H:IncludeResourceBundles), but it must not be missed, since parser.ts surfaces 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 with UnsatisfiedLinkError: Can't load library: zip followed by NoClassDefFoundError: sun.util.calendar.ZoneInfoFile$Checksum. Two fixtures affected. This one has no known workaround yet.
  • Stack traces are raw wasm-function[15242]:0x4ecead with no symbols.

Assessment

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 @class FQCNs. 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.

Open questions

  • 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 JsAstSink actually 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.

Reproducing

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.