Scala.js & Scala Native

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Scala 3.10 (3.10.0, released 2026-10-12), Scala.js 1.22.0 and Scala Native 0.5.12. Every example was compiled and run through Scala CLI 1.17.1 on JDK 26, Node.js 24.21 and Apple clang 21 on macOS (aarch64), checked on 2026-10-15. The two exceptions, a browser page and an sbt build, are labelled "not run here" where they appear. Scala.js 1.22.0 and Scala Native 0.5.12 both publish compiler plugins for Scala 3.9.0 (LTS) and 3.10.0, so everything here also applies to the 3.9 LTS line; the version-specific points are in Scala 3.9 LTS versus 3.10.

The Scala compiler produces JVM bytecode by default, but the same source can also be compiled to JavaScript (Scala.js) or to a native executable (Scala Native). Both are compiler plugins that stop the pipeline after the front end and emit their own intermediate representation instead of .class files, followed by a whole-program linker.

flowchart LR SRC[Scala 3 source] --> FE[Scala 3 front end] FE --> JVM[JVM backend] FE --> SJS[Scala.js plugin] FE --> SN[Scala Native plugin] JVM --> CLS[.class files] SJS --> IR[.sjsir files] SN --> NIR[.nir files] IR --> LNK1[Scala.js linker] NIR --> LNK2[Scala Native linker + LLVM IR + clang] LNK1 --> JS[JavaScript module] LNK1 --> WASM[WebAssembly module] LNK2 --> BIN[Native executable] CLS --> RUN[JVM] JS --> NODE[Node.js or browser] WASM --> NODE BIN --> OS[Operating system]

The standard library is largely the same on every platform: collections, Option, Either, Future, string interpolation and most of java.lang, java.util and java.time are re-implemented for Scala.js and Scala Native. What differs is what sits around it: threads, files, sockets, reflection, the JVM’s number formatting and (on Scala.js) the whole runtime environment.

Platforms with Scala CLI

Scala CLI chooses the platform per run, either with a command-line flag or a directive. --js and --native (or the directives //> using platform js and //> using platform native) pick the target; --js-version///> using jsVersion and --native-version///> using nativeVersion pin the toolchain (the 3.10.0 distribution’s Scala CLI defaults to 1.22.0 and 0.5.12). The first run downloads the toolchain from Maven Central, as for any other dependency.

//> using scala 3.10.0
//> using platform js
//> using jsVersion 1.22.0

@main def hello(): Unit =
  println("Hello from Scala.js")
  println(s"1 + 1 = ${1 + 1}")
Hello from Scala.js
1 + 1 = 2

Scala CLI compiled the program to .sjsir, linked it and ran the resulting JavaScript on the node found on the PATH (Node.js is not downloaded for you). One practical note from the run above: by default Scala CLI links with a native executable of the Scala.js linker that it downloads from a GitHub release page rather than from Maven Central. If you want the linker to come from Maven Central only (a locked-down CI machine, for instance), pass --js-cli-on-jvm, which runs the same linker on the JVM; the examples on this page were run that way.

The same source with Scala Native, using //> using platform native (the program is identical, only the directive differs):

//> using scala 3.10.0
//> using platform native

@main def hellon(): Unit =
  println("Hello from Scala Native")
Hello from Scala Native

Scala Native compiles the program to LLVM IR and calls clang to produce the executable, so a C toolchain must be installed (Xcode command line tools on macOS, clang on Linux).

Scala.js

Scala.js compiles Scala to JavaScript. Its linker removes everything the program cannot reach, and calling JavaScript from Scala and the other way round is type-checked. This page covers the pieces you meet first.

The Scala.js linker has two modes. Fast linking (fastLinkJS, the default of scala run) links quickly and keeps the output readable; full linking (fullLinkJS, --js-mode release in Scala CLI) runs more optimisations and Google Closure to minify. For the small program used in the WebAssembly section below, the two outputs measured about 195 KB (fast) and 38 KB (full).

-rw-r--r--  195440  out-js     (fast link)
-rw-r--r--   38582  out-full   (full link, Closure: 0 error(s), 0 warning(s))

The module kind says how the output is packaged: NoModule (a script, the default), CommonJSModule (require) or ESModule (import/export). Scala CLI’s --js-module-kind and //> using jsModuleKind take none, commonjs or es. A browser page that loads the script with <script type="module"> wants es; Node.js scripts are happy with the default.

JavaScript interoperability

The three basic tools are:

  • js.Dynamic and js.Dynamic.literal: untyped access to any JavaScript object, like the Dynamic trait on the JVM.

  • Facade types: a Scala trait or class extending js.Object (or @js.native for existing JavaScript classes and globals) that describes the shape of a JavaScript API with static types.

  • Exports: @JSExportTopLevel makes a Scala definition visible to JavaScript.

//> using scala 3.10.0
//> using platform js
//> using jsVersion 1.22.0

import scala.scalajs.js
import scala.scalajs.js.annotation.*

@js.native
@JSGlobal("Math")
object JsMath extends js.Object:
  def hypot(a: Double, b: Double): Double = js.native

trait TrailJson extends js.Object:
  val name: String
  val km: Double

@JSExportTopLevel("trailLabel")
def trailLabel(t: TrailJson): String = s"${t.name} (${t.km} km)"

@main def interop(): Unit =
  println(JsMath.hypot(3, 4))
  val t = js.Dynamic.literal(name = "Pico", km = 12.5).asInstanceOf[TrailJson]
  println(trailLabel(t))
  println(js.JSON.stringify(t))
  val xs = js.Array(1, 2, 3).map(_ * 2)
  println(xs.mkString(","))
  println(js.typeOf(t))
  println(new js.Date(2026, 9, 12).getFullYear())
5
Pico (12.5 km)
{"name":"Pico","km":12.5}
2,4,6
object
2026

JsMath is a facade for the global Math object: @js.native means "the implementation exists in JavaScript", and every member’s body is js.native. TrailJson is a non-native trait extending js.Object, so it is a JS type (a plain JavaScript object at run time, with no Scala class behind it) and js.JSON.stringify prints just its fields. Calling a Scala method from JavaScript needs @JSExportTopLevel; with ESModule the name becomes a named export of the module, with CommonJSModule a property of exports and with NoModule a global.

A frequent mistake is calling JavaScript constructors as if they were Scala case classes. The compiler’s message is precise but unexpected when you come from java.util.Date:

println(js.Date(2026, 9, 12).getFullYear())
-- Error: interop.scala:29:18
29 |  println(js.Date(2026, 9, 12).getFullYear())
   |                  ^^^^
   |          too many arguments for method apply in object Date: (): String

js.Date(...) without new is the JavaScript function call Date(), which returns a string with the current time. Write new js.Date(2026, 9, 12) for the constructor, the way you would in JavaScript.

The DOM with scala-js-dom

Browser APIs (document, Element, MouseEvent, fetch, localStorage) are described by the scala-js-dom library, a collection of facade types. In Scala CLI the :: after the group id makes the _sjs1_3 artifact for Scala.js; in sbt 1.x it is %%%, while in sbt 2 the plain %% adds the platform suffix itself (Cross-platform projects).

//> using scala 3.10.0
//> using platform js
//> using jsVersion 1.22.0
//> using dep org.scala-js::scalajs-dom::2.8.1

import org.scalajs.dom

case class Trail(name: String, km: Double)

val trails = List(Trail("Pico de Aneto", 18.5), Trail("Monte Perdido", 14.0))

@main def page(): Unit =
  val list = dom.document.createElement("ul")
  trails.foreach: t =>
    val li = dom.document.createElement("li")
    li.textContent = s"${t.name}: ${t.km} km"
    list.appendChild(li)
  dom.document.body.appendChild(list)
  val button = dom.document.createElement("button")
  button.textContent = "Count"
  var clicks = 0
  button.addEventListener("click", (_: dom.MouseEvent) =>
    clicks += 1
    button.textContent = s"Clicked $clicks times")
  dom.document.body.appendChild(button)

This program compiles and links with Scala.js 1.22.0 and scalajs-dom 2.8.1. It is not run here in a browser: a browser (or jsdom, which Scala CLI’s --js-dom would install through npm) was not available. Run under plain Node.js, it fails at the first DOM access, which also shows what the facade is: a typed view of a global that must exist at run time.

ReferenceError: document is not defined
    at $c_Ldom$package$.page__V (main.js:1093:14)
    ...
Node.js v24.21.0

The Scala 3 style used above is idiomatic: the event handler is a Scala lambda converted to a JavaScript function by SAM conversion, and var clicks is captured like in any Scala closure. Note that the main method is named page and not dom: a top-level @main def dom() would clash with the dom import ("method dom must be called with () argument").

The WebAssembly backend

Since Scala.js 1.22.0 the WebAssembly backend is considered stable. It compiles the same .sjsir files to a .wasm module plus a small JavaScript loader instead of to JavaScript. The documented requirements are an ECMAScript 2022+ host, an engine with WebAssembly 3.0 support and the ESModule module kind; the documentation lists Node.js 25, Chrome 137, Firefox 134 and Safari 26 as supported engines. Two limitations are documented and worth knowing before you switch: @JSExport and @JSExportAll are silently ignored (JavaScript code cannot call the exported members of Scala classes, though @JSExportTopLevel of functions is unaffected), and only one output module is emitted, which rules out js.dynamicImport and top-level exports into several modules.

In Scala CLI the switch is the directive //> using jsEmitWasm true. Forgetting the module kind gives a clear error:

[error]  Wasm output requires ES modules. Pass --js-module-kind es (or add //> using jsModuleKind es).
//> using scala 3.10.0
//> using platform js
//> using jsVersion 1.22.0
//> using jsMode fastLinkJS
//> using jsEmitWasm true
//> using jsModuleKind es

@main def wasm(): Unit =
  val xs = (1 to 5).map(i => i * i)
  println(xs.sum)
55

Packaging the same program (scala --power package wasm.scala -o out-wasm) writes three files, showing the output really is WebAssembly:

-rw-r--r--   5147  out-wasm/__loader.js
-rw-r--r--   1577  out-wasm/main.js
-rw-r--r--  163439  out-wasm/main.wasm

This ran under Node.js 24.21, which is older than the engines listed in the documentation (Node.js 25); it worked for this small program, but the documented minimum is the one to rely on. The documentation also notes that Wasm output is significantly faster when time is spent in Scala code and may be slower when the program is dominated by JavaScript interop, and that the code size is about twice that of the JavaScript back end in fullLink mode.

Semantics that differ from the JVM

Scala.js represents Scala’s numeric types with JavaScript numbers. Int, Short, Byte and Char have exact 32-bit (or smaller) semantics, Long is emulated, but Float and Double are both JavaScript doubles, so some results differ. The same source on the three platforms:

//> using scala 3.10.0

@main def semantics(): Unit =
  val x: Any = 1.0
  println(s"isInstanceOf[Int]: ${x.isInstanceOf[Int]}")
  println(s"toString: ${1.0}")
  println(s"class: ${x.getClass.getName}")
  println(s"float: ${1.0f / 3}")
  println(s"vm: ${System.getProperty("java.vm.name")}")
Output line JVM Scala.js Scala Native

isInstanceOf[Int]

false

true

false

toString of 1.0

1.0

1

1.0

getClass.getName of 1.0

java.lang.Double

java.lang.Byte

java.lang.Double

1.0f / 3

0.33333334

0.3333333432674408

0.33333334

java.vm.name

Java HotSpot™ 64-Bit Server VM

Scala.js

Scala Native

On Scala.js a Double that happens to hold an integer value is an Int, Byte and Short for run-time type tests, which is why isInstanceOf[Int] is true and getClass reports the smallest matching class. The Float is stored as a double and printed as one. JavaScript is single-threaded, so there are no threads and blocking waits cannot make progress, and some run-time checks that the JVM performs are relaxed. The Scala.js semantics page lists all the differences.

Scala Native

Scala Native compiles Scala to a native executable through LLVM IR and clang. It provides its own Scala 3 standard library and a re-implementation of much of the JDK, a garbage collector in the runtime (default immix; commix, boehm and none are also selectable), and a foreign function interface for calling C.

//> using scala 3.10.0
//> using platform native
//> using nativeVersion 0.5.12

import scala.scalanative.unsafe.*

@extern
object libc:
  def strlen(s: CString): CSize = extern
  def getpid(): CInt = extern
  def abs(i: CInt): CInt = extern

@main def ffi(): Unit =
  Zone:
    val name = toCString("Pico de Aneto")
    println(libc.strlen(name))
  println(libc.abs(-42))
  println(libc.getpid() > 0)
  println(sizeof[Long])
[info] Linking native code (immix gc, none lto) (109 ms)
[info] Total (10330 ms)
13
42
true
8

@extern marks an object whose members are implemented in C and found by the linker; extern is the placeholder body. CString is a pointer to a null-terminated byte array (Ptr[CChar]) and CSize the platform’s size_t. A Zone is a region of manually managed memory freed when the block ends; toCString allocates inside the zone given by the context parameter, which is why the call is inside Zone:. unsafe in the package name is accurate: pointers are not checked, and a wrong signature in an @extern declaration crashes at run time.

Build modes and binary size

Scala CLI’s --native-mode (or //> using nativeMode) selects debug (the default, fast compile), release-fast, release-size or release-full; --native-lto enables link-time optimisation and --native-gc selects the collector. The size of the Hello World executable above, packaged with scala --power package:

Mode Size of the executable (arm64 macOS)

debug

2,034,264 bytes

release-fast

1,686,152 bytes

release-size

1,345,464 bytes

The executables depend only on the system library (otool -L lists libSystem.B.dylib), start immediately and need no JVM, which is the reason to choose Scala Native for command-line tools. release-fast took 17 seconds to build against 9 for debug, so use debug while developing.

Which Scala Toolkit modules work where

The Scala Toolkit bundles MUnit, OS-Lib, uPickle and sttp. The Maven Central artifacts of Toolkit 0.9.2 show which platforms each module supports (a platform has an _sjs1_3 or _native0.5_3 artifact if it is supported):

Module JVM Scala.js Scala Native

MUnit (toolkit-test)

yes

yes

yes

uPickle (JSON)

yes

yes

yes

sttp client 4 (HTTP)

yes

yes

yes

OS-Lib (files and processes)

yes

no

yes

//> using toolkit 0.9.2 resolves to toolkit_sjs1_3 or toolkit_native0.5_3 when you pass --js or --native, and the shared JSON program below was run unchanged on all three platforms:

//> using scala 3.10.0
//> using toolkit 0.9.2

import upickle.default.*

case class Trail(name: String, km: Double) derives ReadWriter

@main def shared(): Unit =
  val json = write(Trail("Pico", 12.5))
  println(json)
  println(read[Trail](json))
  println(System.getProperty("java.vm.name"))
{"name":"Pico","km":12.5}
Trail(Pico,12.5)
Java HotSpot(TM) 64-Bit Server VM        (scala run shared.scala)
Scala.js                                  (scala run shared.scala --js)
Scala Native                              (scala run shared.scala --native)

OS-Lib has no Scala.js artifact because there is no portable file system in a browser, and so using it with --js fails to compile:

//> using scala 3.10.0
//> using toolkit 0.9.2

@main def osjs(): Unit =
  println(os.pwd.last)
-- [E006] Not Found Error: osjs.scala:5:10
5 |  println(os.pwd.last)
  |          ^^
  |          Not found: os
1 error found

The same file with --native printed the directory name scala-js-native. For tests, //> using test.toolkit 0.9.2 also works on all three platforms; the MUnit suite below passes with scala test, scala test --js and scala test --native:

//> using scala 3.10.0
//> using test.toolkit 0.9.2

class TrailTests extends munit.FunSuite:
  test("naismith hours"):
    val hours = 12.0 / 5 + 800.0 / 600
    assertEqualsDouble(hours, 3.7333, 0.001)
TrailTests:
  + naismith hours 0.002s

The pattern works for any library: find the artifact with the platform suffix. A library written in pure Scala with no JVM-only dependencies can be published for all three platforms.

Cross-platform projects

In sbt 1.x, a cross project has one directory of shared sources and one per platform, each with its own dependencies. The sbt-crossproject plugins provide crossProject (version 1.4.0 is published for sbt 1 and sbt 2, but sbt 2 has its own replacement, shown after this build). This build was not run here (sbt was not used for this page); the plugin versions were checked on Maven Central, and the same sources were run through Scala CLI above.

// project/plugins.sbt
addSbtPlugin("org.scala-js"       % "sbt-scalajs"                    % "1.22.0")
addSbtPlugin("org.scala-native"   % "sbt-scala-native"               % "0.5.12")
addSbtPlugin("org.portable-scala" % "sbt-scalajs-crossproject"       % "1.4.0")
addSbtPlugin("org.portable-scala" % "sbt-scala-native-crossproject"  % "1.4.0")

// build.sbt
ThisBuild / scalaVersion := "3.10.0"

lazy val trailhead = crossProject(JVMPlatform, JSPlatform, NativePlatform)
  .crossType(CrossType.Pure)
  .in(file("trailhead"))
  .settings(
    libraryDependencies += "com.lihaoyi" %%% "upickle" % "4.4.3"
  )

In sbt 1.x, %%% appends the platform suffix (_sjs1_3, _native0.5_3) in addition to the Scala suffix; %% would always resolve the JVM artifact. CrossType.Pure uses shared/ plus a platform directory only if needed (js/, jvm/, native/), while CrossType.Full creates the three platform directories by default. The Scala 2 form of the same build is identical apart from scalaVersion, and a Scala 2.13 library can be built for the same three platforms.

sbt 2 builds the same thing without the crossproject plugins. Its built-in projectMatrix takes one jvmPlatform, jsPlatform or nativePlatform call per platform and generates one subproject per platform and Scala version, and %% is platform-aware, so it resolves _sjs1_3 or _native0.5_3 in the Scala.js and Native subprojects and %%% is no longer needed. The Scala.js and Scala Native plugins are still added (both are published for sbt 2). This build follows the sbt 2 documentation and was not run here either:

// project/plugins.sbt (sbt 2)
addSbtPlugin("org.scala-js"     % "sbt-scalajs"      % "1.22.0")
addSbtPlugin("org.scala-native" % "sbt-scala-native" % "0.5.12")

// build.sbt (sbt 2)
scalaVersion := "3.10.0"

lazy val trailhead = (projectMatrix in file("trailhead"))
  .settings(
    libraryDependencies += "com.lihaoyi" %% "upickle" % "4.4.3"
  )
  .jvmPlatform(scalaVersions = Seq("3.10.0"))
  .jsPlatform(scalaVersions = Seq("3.10.0"))
  .nativePlatform(scalaVersions = Seq("3.10.0"))

The sbt 2 changes as a whole are on sbt & Build Tools.

Comparison with Java and Kotlin

Aspect Scala Java and Kotlin

JavaScript

Scala.js: whole program compiled from Scala source; uses ordinary Scala libraries published for _sjs1_3

Java has no official JavaScript target (TeaVM and GWT are third party); Kotlin/JS is a Kotlin Multiplatform target

WebAssembly

Scala.js Wasm backend, stable in 1.22 (JS interop semantics unchanged)

Kotlin/Wasm (Kotlin Multiplatform)

Native executable

Scala Native (LLVM, own runtime and GC)

Java: GraalVM Native Image (ahead-of-time compile of a JVM application, see the Java build and tooling page); Kotlin/Native (LLVM)

Shared code

crossProject in sbt; Mill, Scala CLI and others

Kotlin Multiplatform with expect/actual; Java has no equivalent

The practical difference is that Scala treats the platforms as compiler plugins on the same language, so there is no expect/actual: platform-specific code lives in separate source directories (or behind a trait and a platform given). Kotlin’s multiplatform model is a first-class language feature; Scala’s is a build convention.

Pitfalls

  • Using a JVM-only library: Scala.js and Scala Native need artifacts published for the platform. A missing one shows as Not found at compile time or as a resolution error (os above).

  • Blocking in Scala.js: there is one thread, so Await.result on a Future that depends on the same event loop cannot make progress. Use callbacks or Future combinators and let the event loop run.

  • Using @JSExport with the Wasm backend: it is ignored without warning; use @JSExportTopLevel for functions and test the exported API on the target backend.

  • Running DOM code under Node.js: ReferenceError: document is not defined.

  • Linking errors for unimplemented JDK members: the linker reports the method that is missing from the Scala.js or Scala Native re-implementation of the JDK; avoid it or use a library that wraps it per platform.

  • Forgetting that clang must be installed for Scala Native, and that release modes are slower to build.

  • Relying on JVM number formatting: 1.0.toString prints 1 on Scala.js.

Scala 3.9 LTS versus 3.10

Scala.js 1.22.0 and Scala Native 0.5.12 publish plugins for both 3.9.0 and 3.10.0, so every example on this page works with //> using scala 3.9.0 (the programs use only syntax available since 3.3 LTS). The Toolkit 0.9.2 artifacts for both platforms were built against the Scala 3.3 library (scala3-library_sjs1_3 3.3.7, scala3lib_native0.5_3 3.3.7+0.5.10) and run on later compilers.

Where the books are outdated

Books written before 2024 describe Scala.js 1.x with sbt-scalajs only, Scala Native 0.4 and its NativeConfig build settings, and a JavaScript backend without WebAssembly. They also predate Scala CLI as the way to try a platform with one flag, the Wasm backend (experimental until 1.22.0) and the Toolkit modules for Scala.js and Scala Native. Prefer the current official documentation for the build setup.