ClojureScript Advanced Compilation: Dead Code Elimination and the Externs Trade-off
ClojureScript's :advanced compilation can shrink a Reagent app from 180 KB to 45 KB gzipped, but only if you describe every external JavaScript dependency in externs files. This post shows the size gains, the externs pitfall, and a practical CI profile strategy.
12 Oct 2025, 03:48 UTC

The production bundle problem
ClojureScript developers love the REPL-driven workflow, but shipping a 200 KB JavaScript payload to users is a different story. The Google Closure Compiler’s :advanced optimization mode promises 60–90% size reduction by treating the whole program as a single compilation unit. The catch: it assumes a closed world, so any JavaScript you touch from outside (React, browser globals, npm packages) must be described in externs or the compiler will rename or delete those references. This post walks through what :advanced actually does, a concrete size comparison, the externs pitfall, and a practical CI strategy.
Whole-program optimization in a nutshell
When you compile with :optimizations :advanced, the Closure Compiler builds a call graph from your entry point (usually a :main namespace or a ^:export function). It then:
- Eliminates dead code – functions, vars, and entire namespaces that aren’t reachable are dropped.
- Renames symbols – local bindings, function parameters, and property names become single characters (
a,b…). This is safe because ClojureScript’s emitted JavaScript follows predictable naming patterns. - Inlines aggressively – small functions are copied into call sites, enabling further optimizations.
The compiler version is pinned per ClojureScript release (e.g., CLJS 1.11.132 bundles Closure Compiler v20230802). Lock the ClojureScript version in deps.edn or project.clj for reproducible builds.
Worked example: a minimal Reagent app
Create a tiny project to see the numbers yourself:
# terminal, project root
clojure -T:project/create :template reagent-minimal :name demo
cd demo
# add a build alias to deps.edn if missing
clojure -M:build :advanced # produces target/public/js/compiled/out/main.js
clojure -M:build :simple # for comparison
Typical results (gzipped):
| Optimization | Output size (gz) |
|---|---|
:simple | ~180 KB |
:advanced | ~45 KB |
The advanced build strips unused Reagent internals, uncalled cljs.core functions, and renames every local binding. No code changes required—just the compiler flag.
The externs boundary: where it breaks
Advanced compilation only knows about code it can see. Call a global that isn’t declared in externs and the compiler will mangle the name:
;; src/demo/core.cljs
(ns demo.core
(:require [reagent.core :as r]))
(defn init []
(js/console.log "starting")
(js/MyCustomLib.doSomething) ; <-- external global, no externs
(r/render ["div" "Hello"]
(js/document.getElementById "app")))
Build with :advanced and open the page: TypeError: a.b is not a function (or similar). The compiler renamed MyCustomLib.doSomething because it didn’t know the name must stay intact.
Fix: add an externs file and reference it in the build config:
// resources/externs/custom.js
/** @externs */
var MyCustomLib = {};
MyCustomLib.doSomething = function() {};
;; build.edn or deps.edn alias
{:optimizations :advanced
:externs ["resources/externs/custom.js"]
:main demo.core/init}
Rebuild and the error disappears. ClojureScript 1.11+ ships curated externs for React, ReactDOM, and a few others, but any custom global or npm package consumed via :npm-deps (or Shadow-CLJS) needs its own externs. Missing externs are the most common cause of production-only bugs.
Practical engineering decision: profiles and debugging
Don’t run :advanced during development—REPL reload and readable stack traces matter more. A typical setup:
- Dev:
:optimizations :none(or:simple) with source maps, hot reload via Figwheel/Shadow-CLJS. - CI / Staging:
:advancedwith:pseudo-names true– keeps renamed symbols readable (a→a$) so you can debug staging without losing size benefits. - Production:
:advanced(no pseudo-names) for minimal payload.
Run the test suite under both :simple and :advanced in CI to catch externs regressions early. Source maps for advanced builds are large and may not map perfectly due to inlining; reproducing an issue with :pseudo-names or :pretty-print true is often faster than deciphering a minified stack trace.
Checklist before you ship
- Lock ClojureScript version in
deps.edn(org.clojure/clojurescript {:mvn/version "1.11.132"}). - Declare every external global in an externs file (or use
^jstype hints on interop arguments). - Add a CI step:
clojure -M:build :advanced && clojure -M:test :advanced. - Verify gzipped output size:
gzip -c target/public/js/compiled/out/main.js | wc -c. - Keep a
:simpleprofile for local debugging; switch to:pseudo-nameswhen you need to inspect a staging build.
Advanced compilation is a powerful lever—use it deliberately, guard the externs boundary, and you’ll ship a fraction of the bytes without rewriting your ClojureScript code.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.