Experimental WebAssembly backend

Experimental WebAssembly backend

Since Scala.js 1.17.0, there is an experimental WebAssembly backend (Wasm for short). Under some conditions, it is designed to be a drop-in replacement for the usual JavaScript backend.

Experimental status

Being experimental means that:

  • The Wasm backend may be removed in a future minor version of Scala.js (or moved to a separate plugin).
  • Future versions of Scala.js may emit Wasm that requires newer versions of Wasm engines, dropping support for older engines.

However, we do not expect the the Wasm backend to be any less correct than the JS backend, modulo the limitations listed below. Feel free to report any issue you may experience with the same expectations as for the JS backend.

Non-functional aspects, notably performance and size of the generated code, may not be as good as the JS backend for now. The backend is also not incremental yet, which means a slower fastLinkJS in the development cycle.

Requirements

The Wasm backend emits code with the following requirements:

  • A JavaScript host (i.e., we do not currently generate standalone Wasm)
  • A Wasm engine with support for:
    • Wasm 3.0
    • Wasm GC
    • Exception handling, including the latest exnref-based variant
  • The ESModule module kind (see emitting modules)

Supported engines include Node.js 23, Chrome, Firefox and Safari. Node.js and Chrome currently require using some experimental flags (see below).

Language semantics

The Wasm backend is nothing but an alternative backend for the Scala.js language. Its semantics are the same as Scala.js-on-JS, including JavaScript interoperability features, with one big limitation.

Limitation: no @JSExport support

Due to the current feature set of Wasm, it is not possible to implement the semantics of @JSExport. Therefore, the Wasm backend currently silently ignores all @JSExport and @JSExportAll annotations (the latter being sugar for many @JSExports).

This limitation has the following consequences:

  • JavaScript code cannot call @JSExported methods of Scala classes.
  • Since that includes toString(), instances of Scala classes cannot be converted to string from JavaScript, including as part of string concatenation.

(String concatenation in Scala.js code is supported.)

Limitation: no support for emitting multiple modules

The WebAssembly backend does not yet support emitting multiple modules. The module split style must be set to ModuleSplitStyle.FewestModules (which is the default). Moreover, the codebase must not contain any feature that require emitting multiple modules: @JSExportToplevel annotations with several module names, or js.dynamicImport. We expect to lift that limitation in the future.

Minimal setup

The following sbt setup enables the Wasm backend and configures flags for Node.js 23.

// Emit ES modules with the Wasm backend
scalaJSLinkerConfig := {
  scalaJSLinkerConfig.value
    .withExperimentalUseWebAssembly(true) // use the Wasm backend
    .withModuleKind(ModuleKind.ESModule)  // required by the Wasm backend
},

// Configure Node.js (at least v23) to support the required Wasm features
jsEnv := {
  val config = NodeJSEnv.Config()
    .withArgs(List(
      "--experimental-wasm-exnref", // always required
      "--experimental-wasm-jspi", // required for js.async/js.await
      "--experimental-wasm-imported-strings", // optional (good for performance)
      "--turboshaft-wasm", // optional, but significantly increases stability
    ))
  new NodeJSEnv(config)
},

Compared to a setup with ES modules with the JS backend, the above setup should be a drop-in replacement. The backend emits ES modules with the same layout and interface as those produced by the JS backend.

Supported engines

Here are some engines known to support enough Wasm features.

Node.js 23

As mentioned above, Node.js 23 and above requires the following flags:

  • --experimental-wasm-exnref: always required
  • --experimental-wasm-jspi: required to use js.async/js.await
  • --experimental-wasm-imported-strings: optional (good for performance)
  • --turboshaft-wasm: optional, bug significantly increases stability

Chrome

In chrome://flags/, enable “Experimental WebAssembly”.

For js.async/js.await support, also enable “Experimental WebAssembly JavaScript Promise Integration (JSPI)”.

Firefox

Firefox supports all the “always required” features since v131, with enhanced performance since v134.

For js.async/js.await support, go to about:config, enable javascript.options.wasm_js_promise_integration. This may require a Firefox Nightly build when you read these lines, although it should be available by default soon.

Safari

Safari supports all the “always required” features since v18.4.

js.async/js.await is not supported yet, however.

Performance

If the performance of your application depends a lot on JavaScript interop, the Wasm output may be (significantly) slower than the JS output.

For applications whose performance is dominated by computions inside Scala code, the Wasm output should be significantly faster than the JS output (geomean 15% lower run time across our benchmarks).

Further work on improving performance is ongoing. Keep in mind that performance work on the Wasm backend is a few months old, compared to a decade of optimizations in the JS backend.

Code size

The generated code size of the Wasm backend is currently about twice as big as the JS backend in fullLink mode.

We hope to significantly improve code size in the future by using wasm-opt, a Wasm-to-Wasm optimizer.

Implementation details

Looking for some implementation details of how we compile Scala.js to WebAssembly? Start with the technical readme of the Wasm backend.