skip to content

How does Dead Code Elimination work in the Kotlin/JS IR backend, and how does it affect bundle size of kotlin-stdlib-js?

level: middleimportance: should knowfreq 45%

answer

  1. DCE = tree-shaking from reachable roots
  2. Trims most of kotlin-stdlib-js
  3. IR does it natively, legacy did it bolt-on
  4. Production builds only — measure there
  5. @JsExport/dynamic keep code alive

basics

~10 s

Dead Code Elimination removes Kotlin code your app never uses from the final JavaScript. That keeps the bundle small, so you don't ship the whole standard library when you only call a few functions.

solid answer

~40 s

Dead Code Elimination (DCE) is whole-program tree-shaking performed by the IR backend: starting from reachable entry points it walks the call graph and drops unreferenced declarations, including most of the large kotlin-stdlib-js. The legacy backend had a separate, weaker DCE step; the IR backend does it natively and far more aggressively, which is a primary reason IR ships smaller bundles. DCE runs for production binaries (e.g. browserProductionWebpack / the production distribution), not necessarily development builds, so size should be measured on a production build. Things that defeat it: reflection-style dynamic access, @JsExport surfaces that must stay public, and code reachable only through external/dynamic boundaries. You can inspect what survived in the production distribution output. Minification (Terser via webpack) is a separate later step that shrinks names/whitespace.

code

kotlin · 6 lines
kotlin
// @JsExport forces retention: an exported symbol is a DCE root,
// so it and everything it touches survives tree-shaking.
@JsExport
fun publicApi(): Int = expensiveButReachable()

private fun expensiveButReachable(): Int = 42

go deeper

for a junior

Knows DCE removes unused code to shrink the bundle.

for a middle

Explains reachability roots, that IR does DCE natively, and that it runs on production builds.

for a senior

Diagnoses why a bundle is large (dev build, broad @JsExport, dynamic boundaries) and separates DCE from minification.

for a principal

Sets bundle-size budgets, governs the @JsExport surface across a multiplatform codebase, and reasons about reachability vs. tooling tradeoffs.

## What DCE is **Dead Code Elimination (DCE)**, also called **tree-shaking**, is the removal of code that can never execute. The compiler computes a **reachability graph**: it starts from **roots** (your executable's entry point and any explicitly exported declarations) and keeps only declarations transitively referenced from those roots. Everything else is discarded. ## Why it matters for kotlin-stdlib-js `kotlin-stdlib-js` is large — collections, sequences, text, math, coroutines support, etc. A typical app uses a small slice. Without DCE you'd ship the whole library to the browser. With the IR backend's DCE, only the stdlib declarations you actually reach end up in the bundle, often cutting size dramatically. ## IR vs legacy The legacy backend ran DCE as a **bolt-on post-processing tool** with limited insight. The **IR backend performs DCE natively** over its intermediate representation with full call-graph knowledge, so it is more aggressive and reliable. This smaller output is one of the headline reasons the project moved everyone to IR. ## When it runs DCE is applied for **production** binaries. Tasks like `browserProductionWebpack` and the production distribution apply full elimination; **development** builds may skip or relax it for faster incremental compilation. **Always measure bundle size on a production build.** ## What defeats DCE - **`@JsExport`** declarations: anything exported to JS/TypeScript is a root and must be retained, along with what it references. - **`external` / `dynamic`** boundaries and reflection-like dynamic lookups the compiler can't statically resolve. - Side-effecting top-level initializers that the graph must keep. ```kotlin // Only sumOf is reached from main -> the rest of the stdlib // math/text/collections you never call gets eliminated. fun main() { val total = listOf(1, 2, 3).sumOf { it * 2 } println(total) } ``` ## DCE vs minification DCE *removes* unreachable declarations. **Minification** (webpack + Terser) is a *separate, later* step that renames identifiers and strips whitespace. Both shrink the bundle but address different things; you usually want both for production. ## How to inspect Look at the production distribution output and the webpack bundle analyzer to confirm which declarations survived and where size is going.

  • Why might your development bundle be much larger than the production one?
    Full DCE and minification run for production. Development builds favor fast incremental compilation and may skip aggressive elimination, so size is only meaningful on a production build.
  • How can @JsExport bloat a bundle?
    Every exported declaration is a retention root, so it and its transitive references can't be tree-shaken. Exporting too broad a surface keeps otherwise-dead code alive.

DCE is like packing only the clothes you'll wear on the trip; you don't haul the whole wardrobe just because you own it.

saying these in an interview costs you the question

  • Judging bundle size from a development build
  • Thinking DCE and minification are the same step
  • Claiming the IR backend needs a separate DCE tool like legacy did
  • Assuming everything in stdlib always ships
  • Not knowing @JsExport defeats elimination

context