Live examples: pages that run compiled Julia in
the browser (ODE solvers, a ModelingToolkit model, Julia's parser, image filters), built from
main.
A compiler from Julia to WebAssembly with garbage collection (WasmGC), working like
juliac --trim. From entry points, it collects the closed world of code they can run,
using Julia's own inference. It compiles that code to a Wasm module and serializes the
Julia objects the code refers to into the module. A program can build its data natively at
compile time (an ODEProblem, an integrator after init, a lookup table), and the module
starts with that data instead of the code that built it.
No Julia runtime is linked. WasmGC provides allocation and garbage collection, and
generated code handles object layout and type tags. What libjulia's C code does
otherwise (IO, 128-bit arithmetic, memmove, Unicode tables, strtod, hashes, random
seeds, BLAS and LAPACK) is a small runtime written in Julia and compiled into the module on
demand, over a few host imports (from JavaScript, or WASI).
Status: experimental. The supported subset is what juliac --trim=safe accepts: no
dynamic dispatch. Julia 1.13 is the main target, and 1.12 also works. Programs that run
today include OrdinaryDiffEq's Tsit5 on an ODEProblem built at compile time, its implicit
Rodas5P on a stiff PDE with LinearSolve's solvers for its linear systems, a
ModelingToolkit model, JuliaSyntax (Julia's parser), Random (bit-identical streams),
dictionaries, exceptions (try/catch/finally, with Wasm's exception handling),
LinearAlgebra (BLAS and LAPACK in the runtime), LinearSolve's pure-Julia solvers (its
default algorithm, the blocked GenericLUFactorization, SimpleLUFactorization, PureKLU on
sparse matrices, Krylov.jl's and its own GMRES), printing to stdout (show of arrays and
tuples too), and the programs of JuliaC.jl's tests (dispatch over concrete types,
reductions, sorting, concatenation).
using WasmCodegen
function f(n::Int)
v = [sin(i * 0.1) for i in 1:n]
println(Core.stdout, "sum = ", sum(v))
return maximum(v)
end
c = WasmCodegen.compile(f, (Int,)) # or compile([(f, (Int,)), (g, (Float64,))])
write("f.wasm", c.bytes)// node --experimental-wasm-exnref run.mjs, with js/julia.mjs copied next to it
import fs from 'node:fs';
import { instantiate } from './julia.mjs';
const { instance } = await instantiate(fs.readFileSync('f.wasm')); // output to the console
console.log(instance.exports.f(100n)); // Int is an i64: a BigIntThe module imports what it needs of the host (output, a random seed, a clock), in one of
two interfaces, Config(host = ...):
:js(the default): a few functions of the import modulejulia, whichjs/julia.mjsprovides (itsinstantiate, orcreateHostfor the imports alone).WasmCodegen.JS_HOSTis its path, to copy next to a module.:wasi: WASI preview 1 (fd_write,random_get,clock_time_get), for WASI runtimes, as other languages compiling to Wasm do (Go'swasip1, Kotlin'swasmWasi). The module exports itsmemory(a page, for the data of WASI calls) and runs its initialization from_initialize, as a WASI reactor;js/julia.mjsruns these too, where JavaScript has no WASI (browsers, Cloudflare Workers), without a filesystem. In Node, withnode:wasi:
import { WASI } from 'node:wasi';
const wasi = new WASI({ version: 'preview1' });
const { instance } = await WebAssembly.instantiate(await WebAssembly.compile(bytes), wasi.getImportObject());
wasi.initialize(instance);
instance.exports.f(100n);A program can call functions of a host of its own: Config(import_libraries = ["lib"]) makes a
ccall((:name, "lib"), ...) a call of the Wasm import lib.name. The arguments and the result
are 32- and 64-bit integers, and an argument may also be bytes (Vector{UInt8}, String,
Memory{UInt8}), which the import gets as their offset and length in the module's memory:
using WasmCodegen
send(n::Int) = (ccall((:message, "page"), Cvoid, (Vector{UInt8},), fill(0x2a, n)); n)
c = WasmCodegen.compile(send, (Int,); config = WasmCodegen.Config(import_libraries = ["page"]))
write("send.wasm", c.bytes) # imports page.message(offset, length), exports memoryconst { instance } = await instantiate(fs.readFileSync('send.wasm'), {
imports: (host) => ({ page: { message: (offset, length) => deliver(host.bytes(offset, length).slice()) } }),
});
instance.exports.send(1024n);host.bytes is a Uint8Array view of the memory, and host.string decodes it from UTF-8. The
bytes are a copy the module makes for the call, valid only until the import returns: a call
from the import back into the module may overwrite them, and growing the memory detaches
existing views, so copy them (.slice()) to keep them. examples/jsbytes gives a message to a page this way, and compares
it with reading it by calls of an export.
WASI file paths use absolute preopen names and a virtual working directory (PWD, or the
first preopen). Preopens must occupy contiguous descriptors starting at 3; the first EBADF
ends discovery. Resolution uses the longest component-prefix match. . and .. after a
symbolic link apply to its target, as the kernel resolves them; cd(f, dir) restores the
old directory by path, so renaming that directory can prevent restoration. Permission bits, uid/gid and block counts are synthesized unless
a mode provider is installed. Default modes do not enforce access or guarantee 0600/0700
privacy for temporary files/directories. Access checks approximate owner bits, not ACLs or
effective credentials. Preview 1 metadata timestamps cannot represent times before 1970.
File IOStreams buffer as Julia's ios.c does (32 KiB; writes held until a flush, a seek, a
read or a close; reads larger than most of the buffer read directly); flush does not promise
durability. Writes out retry EINTR, EAGAIN and no progress from the unwritten suffix, as
ios.c; an error after a partial write leaves that prefix written and is the flush's or the
close's error. Raw jl_fs_read
and jl_fs_write return counts or negative errors as libuv's do: a read is one read (all of
a file's bytes asked, but at its end), a write writes all but at an error after some; both
retry EINTR and report EAGAIN. jl_fs_sendfile copies at most 1 MiB per call. Positional IO
uses fd_pread/fd_pwrite and never moves the source/current file offset.
Creation followed by mode adjustment is not atomic. If a mode query or adjustment fails, the created object is removed and a newly opened descriptor is closed. Path-based mode adjustment has replacement races. Atomic create-with-mode and identity-checked chmod require a stronger host interface and are outside this contract. Directory records up to 1 MiB and symbolic-link targets shorter than 1 MiB are supported; ambiguous full buffers grow to that bound and then report ENAMETOOLONG.
compile throws UnsupportedConstructs with the statements it cannot compile and the
method instances they are in, as juliac's verifier reports what it cannot trim. The module
it returns is optimized by wasm-opt -O3 (Binaryen_jll); Config(optimize=nothing) gives
the raw module, for debugging the code generator only.
Each example builds a page: examples/lorenz solves the Lorenz system with adjustable
parameters, examples/mtk simulates a ModelingToolkit model with sliders,
examples/brusselator solves a stiff reaction-diffusion system with a choice of LinearSolve's
solvers, examples/juliasyntax parses Julia source in the browser, and examples/images
filters images with ImageFiltering's imfilter and a kernel of your choosing. The pages, built
from main by the Pages workflow (examples/site), are at
https://gbaraldi.github.io/WasmCodegen.jl/.
The supported hosts are Node.js, wasmtime and Cloudflare workerd. The sections below say what each needs and what doesn't work yet.
Modules use WasmGC and typed function references. A program that catches exceptions also
uses exception handling with exnref; one that catches none traps on a throw instead.
Node 24 is the reference host: the tests run there. Node 24.21 enables exnref and JSPI
(JavaScript Promise Integration) by default, so the tests pass no Wasm flags. JSPI is
needed by the host's events (host_extensions = [:julia_io], js/julia_io.mjs) and by
Tasks on JSPI (tasks = :jspi, js/julia_task.mjs). On a Node without JSPI by default,
these need --experimental-wasm-jspi.
For speed, keep the defaults: wasm-opt -O3 and Node's own tiering. Node's
--liftoff-only, --no-liftoff and --no-wasm-dynamic-tiering were each slower in
#43.
Only V8 has been checked, through Node and workerd. No minimum browser version has been
verified. The example pages' error message gives the versions that added WebAssembly GC
(Chrome 119, Firefox 120, Safari 18.2), which is not all a module may use. Browser support
for exnref and JSPI hasn't been tested here.
Tested with wasmtime 50.0.1.
-
Support. GC, typed function references and exceptions (
exnref) are Tier 1 and enabled by default with Cranelift (status). A module that imports nothing, such as a Tsit5 solve, runs with the default flags and gives Node's results. Ahost = :jsmodule that importsjuliafunctions doesn't run as is:js/julia.mjsimplements them in JavaScript, and an embedder would have to provide its own. -
WASI imports. wasmtime rejects
host = :wasimodules built with the current WasmBuilder at instantiation, withtypes incompatible: WasmBuilder puts every type in one recursion group. Modules built with gbaraldi/WasmBuilder.jl#2 (import types in recursion groups of their own, not merged) run, and givenode:wasi's output. -
GC heap size. The default collector is the copying collector. On a Dict/String loop that keeps a Dict alive, its heap stayed small and it collected constantly; a 64 MiB initial heap made the loop about 5.6× faster (4.76 s to 0.85 s), for about 64 MiB more memory. For allocation-heavy code, try a bigger initial heap:
wasmtime run -O gc-heap-initial-size=67108864 --invoke f module.wasm 100An embedder sets
Config::gc_heap_initial_size. Keep the copying collector: with-C collector=drcthe same loop took about 4× as long as with the default, and DRC doesn't collect cycles. -
Tasks. Tasks on JSPI (#37) import JSPI functions from JavaScript, so they don't run on wasmtime. Wasm stack switching could replace them, but wasmtime has it at Tier 3, off by default, and only on x86_64 Linux.
-
Threads. Shared memory and atomics (Tier 2) share linear memory only, not WasmGC objects, which every Julia object is here. Sharing those needs shared-everything threads, which wasmtime hasn't implemented (#9466). wasi-threads, a thread-spawning ABI, is legacy and Tier 3 in wasmtime. Independent instances on separate threads need none of these.
Supported on js/julia.mjs, for host = :js and host = :wasi modules, with
js/julia_task.mjs for Tasks on JSPI and js/julia_io.mjs for timers. Tested locally with
workerd 1.20261031.1 (V8 15.7) and not deployed. examples/workerd
serves a Worker that runs a Lorenz solve, the tests' Tasks program (#37: tasks, timers,
channels) and a WASI program that reads its environment and the clock, and compares their
output with Node's and native Julia's.
- WASI from
js/julia.mjs. workerd'snode:wasithrowsERR_METHOD_NOT_IMPLEMENTEDfrom its constructor.createHost({ env, args })provides WASI preview 1 instead: the environment, the clocks, randomness and the standard streams, and arguments, which WasmCodegen doesn't read yet (a module'sARGSare still those of the process that compiled it, wherejuliacempties them and fills them from the command line). It exposes no filesystem: no directory is preopened (workerd's own/tmpand/bundleincluded), so file operations fail as for a path out of the preopens. - Modules compiled ahead of time. Compiling at request time is disallowed ("Wasm code
generation disallowed by embedder"). Configure each module as a Wasm module of the
Worker (
(name = "m.wasm", wasm = embed "m.wasm")in workerd's configuration;import m from './m.wasm'with Wrangler) and pass theWebAssembly.Moduleit imports toinstantiateorWebAssembly.instantiate. - Compatibility flags.
nodejs_compat, forjulia_io.mjsandjulia_task.mjs, is on by default from the compatibility date 2026-08-04. JSPI needs no flag. JS string builtins (wasm:js-string) and string constants needwasm_esm_integration_builtins(compatibilityFlags = ["wasm_esm_integration_builtins"], orcompatibility_flagsinwrangler.toml); WasmCodegen's modules use neither. - Production limits (limits), which local workerd doesn't enforce: 128 MB of memory per isolate, including Wasm; CPU time per HTTP request, 10 ms on the Free plan and 30 s by default (up to 5 minutes) on the Paid plan; 64 MiB of uncompressed bundle; 1 s of startup. A Worker's clocks advance only between I/O operations.
- WasmEdge 0.18.1 is not a supported host, and WasmCodegen's output hasn't been tried there. Its 0.18.0 release turned on the Wasm 3.0 proposals, including exception handling, function references and tail calls, by default. Its proposal tracker still lists GC as unfinished (a real runtime collector, #4156), as well as stack switching and the component model.
- Other engines and runtimes haven't been tried: Wasmer, wazero, Deno and Bun.
- The program's world. The program is inferred in a world with juliac's trim patches
(method redefinitions from JuliaC.jl) and the pseudo-runtime's own (
src/trim), such as IO through a host import instead of libuv. The patches are deleted afterwards. The compiler itself runs in the world it was loaded in. A long-lived process (a compile server, a system image) can install the patches once withinstall_trim_patches!()instead, so that compilations do not invalidate the native code of the packages it keeps loaded (DESIGN.md §1). - Collecting.
Core.Compiler.compile!with a Wasm interpreter, as juliac'stypeinf_ext_topleveldoes, so the code compiled is the code juliac compiles. - Structuring. Each function's IR is turned into structured control flow by
IRStructurizer.jl, with structured exception
handling (
exceptions = :structured). Until a release has it,Project.tomlinstalls it from a commit of its main branch. - Emitting. Values follow JuliaCodegen.jl's representations. Isbits values are inline
(Wasm locals, multi-value returns), everything else is a WasmGC object with a type tag.
Memoryis a Wasm array, and Base'sArraycode compiles from its Julia source. Pointers are an address plus the object they point into, so Base's pointer code works. Modules are built and validated with WasmBuilder.jl. - Optimizing. The code generator emits simple code (a local per value, a helper per
type for memory access and boxing) and leaves locals, the operand stack and inlining to
wasm-opt. - The image. Constants and global variables, and everything reachable from them, become immutable globals in the module, with identity preserved and cycles completed by the start function.
DESIGN.md describes each part.
Pkg.test() compiles each test program, runs it in Node.js 24 (NodeJS_24_jll), and
compares the result with native Julia. Modules are validated by WasmBuilder, and also by
wasm-tools when it is installed.