ClojureScript Advanced Compilation: Shrinking Bundles Without Breaking Interop
Learn how ClojureScript's :advanced compilation shrinks production bundles through dead‑code elimination and symbol renaming, while keeping JavaScript interop reliable via :foreign-libs and careful export handling.
23 Oct 2025, 02:27 UTC

The problem: production bundles that are too large
When a ClojureScript app ships to users, every kilobyte matters. The default :whitespace or :simple optimizations keep the original symbol names and leave dead code in the output, which can push a modest SPA well past 500 KB gzipped. The :advanced compilation mode promises dramatic size reductions by renaming symbols and eliminating unused code, but it also imposes strict requirements on JavaScript interop. This post shows how to get the size wins while keeping your interop safe.
How :advanced changes the output
Advanced compilation performs two aggressive passes:
- Symbol renaming – every ClojureScript var, function, and namespace gets a short, unique identifier (e.g.,
a.b.c→x.Y). - Dead‑code elimination (DCE) – any var that the compiler can prove is never referenced is stripped from the final JavaScript.
Because the compiler rewrites names, any JavaScript code that reaches into ClojureScript by string (e.g., js/console.log(my.ns.myFn)) or relies on the original property name will break. The same applies to external libraries that expect a specific global shape.
Making external JavaScript play nice
Foreign libs configuration
The :foreign-libs key tells the compiler about third‑party scripts so they become part of the same closure that DCE analyses. A minimal entry looks like:
{:foreign-libs
[{:file "node_modules/lodash/lodash.min.js"
:provides ["lodash"]
:global-exports {lodash _}}]}
With :provides the compiler knows the module name, and :global-exports maps the global _ to a ClojureScript namespace lodash. The optimizer can now drop unused lodash functions just like it drops unused ClojureScript vars.
Node.js target caveats
When targeting :nodejs with :advanced, avoid implicit globals such as process or require. Declare them explicitly via :module interop or :npm-deps so the compiler can see the dependency graph and keep DCE effective.
Worked example: a tiny CLI tool
Assume a project built with cljs.main (ClojureScript 1.11.132) and deps.edn. The source contains a single entry namespace my.cli that prints a greeting using chalk from npm.
deps.edn
{:deps {org.clojure/clojurescript {:mvn/version "1.11.132"}
npm-deps {chalk "5.3.0"}}}
build.edn (advanced)
{:main my.cli
:output-to "out/main.js"
:target :nodejs
:optimizations :advanced
:foreign-libs []
:npm-deps {chalk "5.3.0"}
:source-map true
:source-map-asset-path "js/"}
Command to compile
clj -M -m cljs.main --compile-opts build.edn
Run the same command with :optimizations :whitespace to produce a baseline bundle.
Size comparison (illustrative)
| Optimization | Raw JS (KB) | Gzipped (KB) |
|---|---|---|
| :whitespace | 420 | 115 |
| :advanced | 78 | 22 |
The advanced build is roughly 5× smaller gzipped. The :source-map file lets you map the minified identifiers back to my.cli symbols for debugging.
Trade‑offs and limitations
- String‑based eval / dynamic property access – code that does
(js/eval "my.ns.foo")or(aget obj "bar")will fail because the property name has been renamed. Mark such vars with^:exportor keep them in a separate:nonebuild. - Source‑map fidelity – advanced builds may omit local variable names. Enable
:source-map "out/main.js.map"and verify with a source‑map viewer (e.g., Chrome DevTools) that stack traces resolve to the original namespaces. - Compilation time – the whole‑program analysis adds seconds to the build; for large codebases consider incremental CI caching.
Verifying the result
- Compile twice (whitespace + advanced) and compare
wc -c out/main.jsandgzip -c out/main.js | wc -c. - Open the generated
.mapfile in a source‑map viewer; confirm that a minified function likefunction a(b){…}maps tomy.cli/greet. - Run the Node artifact (
node out/main.js) and ensure the colored output fromchalkappears – this proves the foreign lib survived DCE.
Closing checklist
- Add
:optimizations :advancedto your production build config. - Declare every external JS dependency via
:foreign-libsor:npm-deps. - Export only the symbols you truly need to keep (
^:export). - Generate and archive a source map for post‑mortem debugging.
- Automate the size‑comparison step in CI to catch regressions early.
Follow these steps and you’ll ship a lean, fast‑loading ClojureScript bundle without surprising runtime breakage.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.