skip to content

Your team is migrating a Kotlin/JS app from the legacy backend to IR and bundle size unexpectedly grew. How do you reason about and resolve this?

level: principalimportance: nice to knowfreq 18%

answer

  1. IR should shrink, not grow — suspect config
  2. Measure production, not dev
  3. @JsExport surface = retention roots
  4. ESM enables better tree-shaking than UMD
  5. Add a CI bundle-size budget

basics

~20 s

Check that you measured a production build, look for broad @JsExport that blocks code removal, confirm the right module format, and verify your JS interop and npm dependencies still tree-shake. The IR backend usually shrinks bundles, so growth signals a misconfiguration.

solid answer

~40 s

The IR backend normally ships smaller bundles than legacy thanks to native, aggressive Dead Code Elimination, so a size regression almost always points to configuration rather than the backend. Triage: (1) confirm you compared production builds (browserProductionWebpack) — dev builds skip full DCE; (2) audit the @JsExport surface, since every export is a retention root and over-exporting (especially @file:JsExport) defeats tree-shaking; (3) verify the chosen module kind — ESM (useEsModules) gives bundlers static analysis that UMD can't; (4) check that external/@JsModule npm deps and dynamic boundaries aren't pinning code; (5) re-run minification (Terser) and a bundle analyzer to attribute size. Also account for known IR runtime/stdlib packaging differences and per-file granular output. The principal-level point: instrument with a size budget in CI so regressions are caught structurally, not anecdotally.

code

kotlin · 9 lines
kotlin
kotlin {
    js(IR) {
        browser()
        useEsModules()
        binaries.executable()
    }
}
// Anti-pattern to hunt for during migration:
// @file:JsExport  <-- roots the entire file, defeating DCE

go deeper

for a junior

Knows bundle size depends on unused code being removed and that production builds matter.

for a middle

Checks production build and @JsExport surface and knows minification is separate from DCE.

for a senior

Runs a full triage across module kind, interop pins, and analyzer attribution to find the regression's source.

for a principal

Institutes a CI size budget and export-surface governance so the fix is structural and durable, not a one-off.

## Frame the expectation The **IR backend's native Dead Code Elimination** is more aggressive than legacy's bolt-on DCE, so the *expected* migration outcome is a **smaller** bundle. A regression is a signal that something is mis-set — treat it as a misconfiguration hunt, not a backend flaw. ## Systematic triage 1. **Are you measuring production?** Full DCE and minification run for **production** binaries (`browserProductionWebpack` / production distribution). A dev build legitimately looks bloated. Re-measure on production first — this resolves the majority of false alarms. 2. **Audit the `@JsExport` surface.** Every exported declaration is a **DCE retention root**. A stray `@file:JsExport` or an over-broad public API keeps otherwise-dead code (and its transitive references) alive. Shrink the export surface to a deliberate facade. 3. **Check the module kind.** UMD output isn't statically analyzable; **ESM** (`useEsModules()`) with granular per-file output lets webpack/Terser and native tooling tree-shake far better. Migrating module format alongside the backend can swing size. 4. **Inspect interop pins.** `external` / `@JsModule` npm dependencies, `dynamic` access, and reflection-like patterns prevent elimination of whatever they touch. Confirm dependencies themselves tree-shake. 5. **Attribute the bytes.** Run a **bundle analyzer** on the production output and confirm **minification (Terser via webpack)** actually ran. Distinguish DCE (removes declarations) from minification (renames/whitespace). 6. **Account for IR packaging differences.** The IR stdlib/runtime is packaged differently than legacy; granular output and runtime helpers can shift the baseline. Compare apples to apples. ## Make it durable The principal move is to **prevent recurrence**: add a **bundle-size budget** check in CI (fail the build past a threshold), snapshot the production analyzer output, and document the intended export surface. That converts a one-off firefight into a guardrail. ```kotlin kotlin { js(IR) { browser() useEsModules() // static analyzability for tree-shaking binaries.executable() } } // Keep @JsExport to a thin facade; wire a size-budget gate in CI. ``` ## Summary judgment If production + ESM + a tight export surface + working minification are all in place, IR will typically beat legacy. Growth after that is usually attributable to a specific pinned dependency or an export you can remove.

  • Why is comparing a dev IR build to a prod legacy build a flawed measurement?
    Full DCE and minification only run for production binaries. A dev IR build skips aggressive elimination, so it looks larger for reasons unrelated to the backend — you must compare production to production.
  • How would you stop this regression from recurring?
    Add a bundle-size budget gate in CI that fails past a threshold, snapshot the production bundle-analyzer output, and document the intended @JsExport facade so the export surface stays small.

It's like a diet that added weight: before blaming the plan, check you weighed yourself at the right time, weren't sneaking snacks (broad exports), and used the right scale (production analyzer).

saying these in an interview costs you the question

  • Blaming the IR backend instead of checking configuration
  • Comparing dev vs production builds
  • Ignoring an over-broad @JsExport / @file:JsExport surface
  • Not knowing module kind affects tree-shaking
  • No CI guardrail, so it regresses again next quarter

context