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.

Advertisement

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.

  1. 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 .sjsir files, the Scala.js intermediate representation, one per class. Libraries ship these files inside jars whose artifact names carry an _sjs1 suffix.
  2. 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.

Scala.js build pipeline: per-file compilation, then whole-program linkingScala sourcesshared + js/scalac + pluginemits .sjsir IRLinker: reachabilityfrom entry pointsOptimizerinlining, dead codeLibrary .sjsirfrom _sjs1 jarsJS emitterES modules / CommonJSWasm emitterES module + .wasmfastLinkJS-fastopt dir, devfullLinkJS-opt dir, prodLinking sees the whole program, so unused library code is dropped; reflection cannot be supported for the same reason
The compiler emits IR per class; the linker loads IR from your code and from _sjs1 library jars, keeps what is reachable from entry points, optimises, and emits JavaScript or a WebAssembly module.

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")
    ).flatten

Regular 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.

Advertisement

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. Int arithmetic is exact 32-bit, as on the JVM. Long has no native JavaScript equivalent, so the JavaScript backend emulates it, which makes Long-heavy loops markedly slower than Int loops. Prefer Int in 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 ClassCastException or ArrayIndexOutOfBoundsException as control flow is therefore wrong in Scala.js; validate before you index.
  • No threads. JavaScript is single-threaded with an event loop. Await.result cannot block, synchronized is a no-op, and a Future runs 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 -opt output 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 Int over Long, arrays over lists in hot loops, and avoid js.Dynamic in inner loops, where it defeats the optimizer.

Failure modes and how to diagnose them

SymptomLikely causeFix
Link error: referring to non-existent classA JVM-only dependency (added with %%) or an unimplemented JDK APIUse %%% and a Scala.js build of the library, or replace the API
Works in dev, wrong result in prodCode relied on a checked undefined behaviour (bad cast, bad index)Validate inputs; run tests against the fullLinkJS stage
TypeError: x is not a functionFacade does not match the real JavaScript API or import kindCheck Default vs Namespace vs named import; test facades
Exported function missing under WasmWasm backend ignores @JSExportKeep a JavaScript build for library consumers
UI freezesLong synchronous computation on the event loopChunk 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

  1. Create a crossProject with a shared module and move one pure validation rule into it; call it from both server and browser.
  2. Switch the web module to ModuleKind.ESModule and serve fastLinkJS output through your bundler with watch mode.
  3. Write facades for the two or three npm modules you actually use, with a unit test that exercises each one.
  4. Add a CI job that runs tests with Global / scalaJSStage := FullOptStage.
  5. Measure the -opt bundle with a source-map explorer and set a size budget that fails the build.
  6. If you do heavy computation in the browser, benchmark the WebAssembly backend against the JavaScript one on your workload before switching.
Key takeaway: Scala.js compiles Scala to intermediate representation, then links the whole program, keeps only reachable code and emits JavaScript or, stably since 1.22.0, WebAssembly. Share pure domain logic between server and browser with a crossProject, use %%% dependencies, write typed facades at the JavaScript boundary, and remember the differences: no threads, no reflection, emulated Long on the JavaScript backend, and undefined behaviours that production builds may not check. Test against the fully optimised stage and measure bundle size in CI.