Scala.js compiles Scala to code that runs in a JavaScript host: browsers, Node.js, Deno or Bun. It is not a transpiler that rewrites syntax. It is a full compiler backend with its own intermediate representation, a whole-program linker and an optimizer, and it can emit either JavaScript or, since version 1.22.0, a WebAssembly module that the project now calls stable. The payoff is that the same case classes, validation rules and business logic run on the server and in the browser, type-checked by one compiler.
This article builds the model from the pipeline up, sets up a real cross-built project, lists the semantics that differ from the JVM, covers interop with the JavaScript ecosystem, and ends with sizing, failure modes and a checklist. If you are new to Scala itself, start with the Scala overview; build-tool mechanics are covered in sbt in depth and the Mill build tool.
The pipeline: compile per file, link the whole program
A Scala.js build has two stages, and most surprises come from forgetting the second one exists.
- Compilation. The ordinary Scala compiler runs with the Scala.js plugin (built into the Scala 3 compiler, a plugin for Scala 2). Besides checking types it emits
.sjsirfiles, the Scala.js intermediate representation, one per class. Libraries ship these files inside jars whose artifact names carry an_sjs1suffix. - Linking. The linker loads the IR of your code and every dependency, starts from the entry points (a main method, module initializers and exported members) and keeps only what is reachable. The optimizer then inlines, specialises and removes dead code, and an emitter writes JavaScript or WebAssembly.
Whole-program linking is why a Scala.js bundle is much smaller than the jars it came from, and it is also why runtime reflection does not exist: the linker must know at build time every class that can be instantiated, so Class.forName and reflective method lookup have nothing to find. Libraries that need to discover structure at runtime on the JVM, such as JSON codecs, use compile-time derivation instead.
Two sbt tasks drive the linker. fastLinkJS runs the fast optimizer and writes a directory with a -fastopt suffix; use it during development. fullLinkJS runs full optimisation into a -opt directory for production. Historically full optimisation used the Google Closure Compiler; Scala.js 1.21.0 deprecated Closure support, so check your version's release notes before relying on it for minification.
Setting up a cross-built project
The most valuable Scala.js setup is not a browser-only app but a project where the server and the browser share a module. The sbt-scalajs-crossproject plugin provides crossProject, which compiles one source tree for both platforms. The version placeholders below are deliberate: take current versions from each project's page rather than from an article.
// project/plugins.sbt
addSbtPlugin("org.scala-js" % "sbt-scalajs" % "1.22.0")
addSbtPlugin("org.portable-scala" % "sbt-scalajs-crossproject" % "<current>")
// build.sbt
lazy val shared = crossProject(JSPlatform, JVMPlatform)
.crossType(CrossType.Pure)
.in(file("shared"))
.settings(scalaVersion := "<current 3.x>")
lazy val web = project
.in(file("web"))
.enablePlugins(ScalaJSPlugin)
.dependsOn(shared.js)
.settings(
scalaVersion := "<current 3.x>",
scalaJSUseMainModuleInitializer := true,
scalaJSLinkerConfig ~= { _.withModuleKind(ModuleKind.ESModule) },
// %%% picks the _sjs1_3 artifact for Scala.js and the plain _3 artifact on the JVM
libraryDependencies += "org.scala-js" %%% "scalajs-dom" % "<current>"
)
lazy val server = project.in(file("server")).dependsOn(shared.jvm)Three details matter. First, %%% instead of %% tells sbt to pick the platform-specific artifact; using %% in a Scala.js project pulls a JVM jar with no IR and fails at link time with missing-class errors. Second, CrossType.Pure means the shared module has a single source directory; use CrossType.Full when each platform needs its own files alongside shared ones. Third, ModuleKind.ESModule makes the output an ES module that modern bundlers and browsers import directly; CommonJSModule exists for older Node tooling, and the default emits a script with no module system.
The shared code is ordinary Scala. Here is validation logic that the server trusts and the browser uses for instant feedback:
// shared/src/main/scala/signup/Validation.scala -- compiled twice: JVM and JS
package signup
final case class Signup(email: String, age: Int)
object Validation:
private val Email = "^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$".r
def check(s: Signup): List[String] =
List(
Option.when(!Email.matches(s.email))("email: not a valid address"),
Option.when(s.age < 13)("age: must be 13 or older")
).flattenRegular expressions are a useful warning here. On the JVM they run on java.util.regex; on Scala.js they are translated to JavaScript regular expressions, and the implementation documents which features it supports. Simple patterns like this one behave identically, but test anything using look-behind, possessive quantifiers or Unicode classes on both platforms.
Semantics that change when you leave the JVM
Scala.js aims to preserve Scala semantics, and for most code it does. The differences are specific and worth memorising, because they surface as bugs that only appear in the browser.
- Numbers.
Intarithmetic is exact 32-bit, as on the JVM.Longhas no native JavaScript equivalent, so the JavaScript backend emulates it, which makesLong-heavy loops markedly slower thanIntloops. PreferIntin hot paths and measure. - Undefined behaviours. Invalid casts, out-of-bounds array access and similar errors are classified as undefined behaviour in Scala.js. Development builds check them and fail loudly; production builds can drop the checks for speed. Code that catches
ClassCastExceptionorArrayIndexOutOfBoundsExceptionas control flow is therefore wrong in Scala.js; validate before you index. - No threads. JavaScript is single-threaded with an event loop.
Await.resultcannot block,synchronizedis a no-op, and aFutureruns its callbacks on the event loop. Effect libraries such as Cats Effect run on Scala.js with an event-loop-backed runtime, which is the practical way to get structured concurrency in the browser. - Missing JDK pieces. Only part of the JDK is implemented.
java.time, for example, comes from a separate library rather than the core, and anything touching files, sockets or reflection is absent. The link step, not the compile step, reports these gaps.
Interop: facades, exports and the dynamic escape hatch
Calling JavaScript from Scala uses facade types: Scala declarations annotated with @js.native that describe an existing JavaScript API and generate no code. @JSImport binds a facade to an ES module import; @JSGlobal binds it to a global variable. Facades for the DOM live in the scalajs-dom library, and tools exist to generate facades from TypeScript declaration files, though generated facades are often large and loosely typed.
import scala.scalajs.js
import scala.scalajs.js.annotation.*
// Facade for an npm module: types only, no code is generated for it
@js.native
@JSImport("dayjs", JSImport.Default)
object dayjs extends js.Object:
def apply(input: String): DayjsObj = js.native
@js.native
trait DayjsObj extends js.Object:
def format(pattern: String): String = js.native
def isValid(): Boolean = js.native
// Export a Scala function so plain JavaScript can import it (JS backend only)
object Api:
@JSExportTopLevel("validateSignup")
def validateSignup(email: String, age: Int): js.Array[String] =
js.Array(signup.Validation.check(signup.Signup(email, age))*)
// Untyped escape hatch: every member access is dynamic and unchecked
val cfg = js.Dynamic.global.window.APP_CONFIG
val apiBase = cfg.apiBase.asInstanceOf[String]Going the other way, @JSExportTopLevel makes a Scala method importable from JavaScript under a chosen name, which is how you ship a Scala.js library to a TypeScript team. Arguments and results should be JavaScript-friendly types such as js.Array, js.Promise and plain strings and numbers, not Scala collections, whose internal shape is not a stable API.
js.Dynamic lets you access members without a facade. It is convenient for one-off configuration reads and dangerous everywhere else, because every access is unchecked and a typo becomes undefined at runtime. Treat it like asInstanceOf: acceptable at a boundary, wrapped immediately in a typed value.
Asynchronous interop is the most common boundary. JavaScript APIs return js.Promise, which converts to a Scala Future and back. Since 1.19.0 Scala.js also offers js.async { ... } with js.await(...), which compiles to native JavaScript async functions rather than callback chains.
Worked example: one validation rule, two runtimes
Put the pieces together. The browser entry point below reads a form, runs the shared Validation.check, shows errors instantly, and only posts valid data. The server runs the same function again, because client checks are a convenience, never a security control.
import org.scalajs.dom
import scala.scalajs.js
import scala.concurrent.ExecutionContext.Implicits.global
import signup.{Signup, Validation}
object Main:
def main(args: Array[String]): Unit =
val form = dom.document.getElementById("signup").asInstanceOf[dom.html.Form]
form.onsubmit = e =>
e.preventDefault()
val email = form.elements.namedItem("email").asInstanceOf[dom.html.Input].value
val age = form.elements.namedItem("age").asInstanceOf[dom.html.Input].value.toIntOption.getOrElse(0)
Validation.check(Signup(email, age)) match
case Nil =>
// fetch returns a js.Promise; toFuture turns it into a Scala Future
val payload = js.JSON.stringify(js.Dynamic.literal(email = email, age = age))
dom.fetch("/api/signup", new dom.RequestInit { method = dom.HttpMethod.POST; body = payload })
.toFuture.foreach(r => dom.console.log(s"server said ${r.status}"))
case errors => show(errors)
def show(errors: List[String]): Unit =
dom.document.getElementById("errors").textContent = errors.mkString("; ")Follow the data. The user submits; the event handler runs on the event loop; Validation.check executes the same compiled rule the server will run; on success dom.fetch returns a promise, converted to a Future whose callback logs the status. On the server, a route built with a library such as Tapir can decode the same Signup type and call the same function, so a rule change is one commit that updates both sides. Tapir endpoint descriptions can also be compiled for Scala.js to generate typed clients, removing hand-written request code entirely.
To run it, sbt ~web/fastLinkJS relinks on every save; a bundler such as Vite serves the -fastopt output with hot reload, and a Scala.js plugin for Vite exists to wire the two together. For production, web/fullLinkJS produces the -opt directory that the bundler packages.
The WebAssembly backend
Scala.js 1.22.0 declared its WebAssembly backend stable. It reuses the same IR and linker and replaces only the emitter, so switching is a linker setting:
// build.sbt: switch the web project to the WebAssembly backend (stable since 1.22.0)
scalaJSLinkerConfig ~= {
_.withModuleKind(ModuleKind.ESModule) // required by the Wasm backend
.withESFeatures(_.withESVersion(ESVersion.ES2022).withUseWebAssembly(true))
}
// Keep a JS-backend build around if you rely on @JSExport or multiple modules:
// the Wasm backend silently ignores @JSExport/@JSExportAll and emits one module.The constraints come straight from the project's documentation. The output needs a JavaScript host supporting ECMAScript 2022; the backend does not produce a standalone Wasm binary for WASI runtimes. The documented minimum engines are Node.js 25, Chrome 137, Firefox 134 and Safari 26. The backend silently ignores @JSExport and @JSExportAll, and it cannot yet emit multiple modules, so js.dynamicImport and multi-module top-level exports are out.
Why switch? The 1.19.0 release notes report Wasm output faster than JavaScript output for computation-heavy code, which makes sense: Wasm has native 64-bit integers, so Long arithmetic stops being emulated. For DOM-heavy applications the gain is smaller, because every DOM call still crosses into JavaScript. Benchmark your own workload in both modes before committing, and keep a JavaScript build if you ship a library that relies on exports.
Bundle size and performance
A minimal Scala.js program is small, but size grows with what you reach, not with what you import. Common sources of growth are the Scala collections library (reaching one rich operation can pull in a lot), string formatting via f interpolators and String.format, java.time, and codec derivation for many types. Practical steps:
- Measure the
-optoutput with a source-map explorer; the linker emits source maps that attribute bytes back to Scala sources. - Keep the browser module thin: share models and pure rules, not server-only utilities.
- Split large applications into several output modules with the linker's module-split settings, so that pages load only the code they use (JavaScript backend only).
- Prefer
IntoverLong, arrays over lists in hot loops, and avoidjs.Dynamicin inner loops, where it defeats the optimizer.
Failure modes and how to diagnose them
| Symptom | Likely cause | Fix |
|---|---|---|
| Link error: referring to non-existent class | A JVM-only dependency (added with %%) or an unimplemented JDK API | Use %%% and a Scala.js build of the library, or replace the API |
| Works in dev, wrong result in prod | Code relied on a checked undefined behaviour (bad cast, bad index) | Validate inputs; run tests against the fullLinkJS stage |
| TypeError: x is not a function | Facade does not match the real JavaScript API or import kind | Check Default vs Namespace vs named import; test facades |
| Exported function missing under Wasm | Wasm backend ignores @JSExport | Keep a JavaScript build for library consumers |
| UI freezes | Long synchronous computation on the event loop | Chunk work, use a Web Worker, or yield via an effect runtime |
Run tests on both stages. Setting Global / scalaJSStage := FullOptStage makes test run against fully optimised output, which is the only way to catch behaviour that differs because production drops checks.
Trade-offs: when Scala.js is the right tool
Scala.js pays off when you already run Scala on the server and the frontend carries real domain logic: pricing, validation, state machines, protocol codecs. Sharing that logic removes a whole class of client-server drift. It is a weaker choice for a thin UI over a REST API, for teams without Scala experience, or when you need the newest npm UI frameworks with first-class TypeScript types, since every library needs a facade.
What to do next
- Create a crossProject with a shared module and move one pure validation rule into it; call it from both server and browser.
- Switch the web module to ModuleKind.ESModule and serve fastLinkJS output through your bundler with watch mode.
- Write facades for the two or three npm modules you actually use, with a unit test that exercises each one.
- Add a CI job that runs tests with Global / scalaJSStage := FullOptStage.
- Measure the -opt bundle with a source-map explorer and set a size budget that fails the build.
- If you do heavy computation in the browser, benchmark the WebAssembly backend against the JavaScript one on your workload before switching.