Running Clojure in WASM Apr 26, 2025 Starting from v25 GraalVM added support for WASM backend for Java programs compiled to native image, which means that it's finally becomes possible to compile and run Clojure programs in WASM! Although WASM backend is in its early days, and doesn't support threading and networking, it is already possible to compile and run single-threaded computational programs in Java/Clojure. If you open browser console now, on this page, you'll see Hello, World! printed in the console. That's a Clojure program talking to you :) (ns core (:gen-class)) (defn -main [& args] (println "Hello, World!")) Binary size The output WASM of this simple program is 5.6MB binary, which can be pruned a bit via wasm-opt tool, just make sure that it doesn't break anything for you. Luckily when compressed (gzip, brotli, etc) the binary becomes just ~2.5MB in size. After running the binary through wasm-opt I got 5MB output, so roughly ~600KB less ones and zeroes. wasm-opt core.js.wasm -o core.js.wasm -Oz --enable-gc --enable-strings --enable-reference-types --enable-exception-handling --enable-bulk-memory --enable-nontrapping-float-to-int In comparison, this basic hello world program in Java results in just 1MB of WASM. public class Main { public static void main(String[] args) { System.out.println("Hello, World!"); } } To give you a sense of how binary size changes with added libraries, using clojure.data.json increases WASM binary by 130KB. Note that if a namespace is required but never used, it's pruned from the output. (ns core (:require [clojure.data.json :as json]) (:gen-class)) (defn -main [& args] (prn (json/write-str {:a 1 :b 2}))) For detailed analysis GraalVM provides a build report, that says that 70% of compiled output is heap snapshot and roughly 50% of it is filled with strings and hash maps. It's interesting that number of methods chanrt shows that majority, 60% of methods are coming from various Java namespaces, while Clojure only occupies 17% of methods. Here's the full interactive build report of the WASM binary running on this page. Speed Running unoptimized binary via Node 23.9.0 - big reduce: {:total-ops 10**1, :total-time 2.6s, :per-op-time 256.3ms} - int/float division: {:total-ops 10**7, :total-time 763.2ms, :per-op-time 76ns} - float division: {:total-ops 10**7, :total-time 398.9ms, :per-op-time 39ns} - integer division: {:total-ops 10**7, :total-time 45.2ms, :per-op-time 4ns} Optimized binary runs about 10% faster. wasm-opt core.js.wasm -o core.js.wasm -O4 --enable-gc --enable-strings --enable-reference-types --enable-exception-handling --enable-bulk-memory --enable-nontrapping-float-to-int - big reduce: {:total-ops 10**1, :total-time 2.3s, :per-op-time 226.0ms} - int/float division: {:total-ops 10**7, :total-time 646.6ms, :per-op-time 64ns} - float division: {:total-ops 10**7, :total-time 348.0ms, :per-op-time 34ns} - integer division: {:total-ops 10**7, :total-time 45.3ms, :per-op-time 4ns} You can run the benchmark yourself on this page, press the button below and wait for logs in the console Run benchmark Same Clojure code, compiled as native image, run 2-3x faster than WASM version - big reduce: {:total-ops 10**1, :total-time 1.0s, :per-op-time 102.0ms} - int/float division: {:total-ops 10**7, :total-time 248.9ms, :per-op-time 24ns} - float division: {:total-ops 10**7, :total-time 87.2ms, :per-op-time 8ns} - integer division: {:total-ops 10**7, :total-time 19.8ms, :per-op-time 1ns} Running the benchmark via Clojure CLI on OpenJDK Temurin-21.0.7+6 runs significantly faster, from 5x to 12x faster to be precise - big reduce: {:total-ops 10**2, :total-time 1.8s, :per-op-time 18.2ms} - int/float division: {:total-ops 10**7, :total-time 326.1ms, :per-op-time 32ns} - float division: {:total-ops 10**7, :total-time 86.8ms, :per-op-time 8ns} - integer division: {:total-ops 10**7, :total-time 24.0ms, :per-op-time 2ns} And finally here's the same code compiled as ClojureScript running on Node 23.9.0, 5x faster than WASM version - big reduce: {:total-ops 10**2, :total-time 4s, :per-op-time 41ms} - int/float division: {:total-ops 10**7, :total-time 40ms, :per-op-time 4ns} - float division: {:total-ops 10**7, :total-time 39ms, :per-op-time 3ns} - integer division: {:total-ops 10**7, :total-time 39ms, :per-op-time 3ns} Which brings us to the following very scientific performance chart: I'm no expert on WASM and GraalVM, so can't really tell anything in defence of it, but I was also surprised that native image runs slower. Of course there's JVM startup but that's a different story. Interop with host environment Ok, now try to press this button... What you see is an example of WASM<->JavaScript interop. Let's have a look how this is implemented with GraalVM. (ns core (:import [browser Browser Callback] [org.graalvm.webimage.api JSObject]) (:gen-class)) (defn as-callback [f] (reify Callback (run [this value] (f value)))) (defn invoke-method [^JSObject object ^String method & args] (.call ^JSObject (.get object method) object (object-array args))) (defn -main [& args] (Browser/main) (let [window (Browser/globalThis) root (Browser/querySelector "#btn-root") button (Browser/createElement "button") on-click (fn [event] (invoke-method window "alert" "Hello, from Clojure in WASM!"))] (.set button "textContent" "Press me") (Browser/addEventListener button "click" (as-callback on-click)) (Browser/appendChild root button))) The main part here is the -main function. This is your typical imperative DOM manipulation: create a button element, add text child, assign event listener and insert the element into the DOM. invoke-method is a helper that executes object methods and as-callback wraps event handler into an object that implements Callback interface. The purpose of this interface is somewhat similar to java.lang.Runnable . That was user facing code, now let's take a look at the "backend" — the interop layer. First of all, org.graalvm.webimage.api provides a set of classes that define various ways of accessing JavaScript environment. The Callback interface has to be a functional interface. Notice that its run method takes an argument of type JSObject . package browser; import org.graalvm.webimage.api.JSObject; @FunctionalInterface public interface Callback { void run(JSObject event); } And finally the Browser class, that's where DOM API is declared for WASM side of things. package browser; import org.graalvm.webimage.api.JS; import org.graalvm.webimage.api.JSObject; public class Browser { public static void main() { try { // TODO GR-62854 Here to ensure run is generated. Remove once // objects passed to @JS methods automatically have their SAM registered. sink(Callback.class.getDeclaredMethod("run", JSObject.class)); } catch (NoSuchMethodException e) { throw new RuntimeException(e); } } @JS("") private static native void sink(Object o); @JS.Coerce @JS("return globalThis;") public static native JSObject globalThis(); @JS.Coerce @JS("return document.querySelector(selector);") public static native JSObject querySelector(String selector); @JS.Coerce @JS("return document.createElement(tag);") public static native JSObject createElement(String tag); @JS.Coerce @JS("return parent.appendChild(child);") public static native void appendChild(JSObject parent, JSObject child); @JS.Coerce @JS("element.addEventListener(eventType, handler);") public static native void addEventListener(JSObject element, String eventType, Callback handler); } This interfacing code is quite simple. @JS decorator takes a string of JavaScript code that will be generated as a part of JS runtime, and native Java method will be bound to that JS code on WASM side. @JS.Coerce @JS("return document.createElement(tag);") public static native JSObject createElement(String tag); You have to make sure that both JS and Java declarations use the same method names, and classes are compiled with -parameters flag to preserve arguments names in the bytecode. The benchmark button on this page calls into WASM binary. Here's how you can export Clojure function into browser's global scope. (.set (Browser/globalThis) "runBenchmark" (as-callback (fn [_] (run-benchmark)))) The main method of the Browser class registers methods of the Callback interface, so they are not removed after compilation. This should go away eventually. You can find the complete Clojure setup in roman01la/graal-clojure-wasm repo.