Code Splitting in ClojureScript with Closure Compiler Modules
Configure ClojureScript code splitting with Closure Compiler modules using cljs.main or shadow-cljs. Define a base module, add feature entry points, verify output sizes, and implement runtime loading with error recovery.
30 Jun 2026, 12:01 UTC

Desired Outcome
Produce multiple JavaScript bundles from a single ClojureScript build so that feature-heavy code (admin panels, charting libraries, rarely used dialogs) loads on demand while a shared base module containing cljs.core and common dependencies loads once. This reduces initial payload and improves Time-to-Interactive for large applications.
Prerequisites
- ClojureScript 1.10.773 or newer for
cljs.mainmodule support, orshadow-cljs2.20+ for the ergonomic:modulesconfiguration. - Familiarity with your build tool (
clj -M --main cljs.mainornpx shadow-cljs) and the project's namespace graph. - A list of candidate entry-point namespaces (e.g.,
app.admin.core,app.charts.dashboard) identified via build reports or manual inspection.
Procedure: cljs.main (Built-in Compiler)
1. Define the Module Graph in build.edn
Create a :modules map where each key is a module ID. The :base module has no :entries (it implicitly contains cljs.core). Feature modules declare :entries (the namespace that triggers the load) and :depends-on #{:base}.
{:main app.core
:output-dir "out"
:optimizations :advanced
:modules
{:base {}
:admin {:entries [app.admin.core]
:depends-on #{:base}}
:charts {:entries [app.charts.dashboard]
:depends-on #{:base}}}}
2. Implement Runtime Loading
In the entry namespace (e.g., app.admin.core), call cljs.loader/load with the module keyword and a callback. The compiler emits a manifest.edn mapping namespaces to module IDs.
(ns app.admin.core
(:require [cljs.loader :as loader]))
(defn init []
(loader/load :admin
(fn [] (println "Admin module loaded"))
(fn [err] (js/console.error "Admin load failed" err))))
3. Build and Verify Output
clj -M --main cljs.main --compile-opts build.edn --compile app.core
Run from the project root. Requires write permission to out/. Expected artifacts: out/base.js, out/admin.js, out/charts.js, and out/manifest.edn.
Procedure: shadow-cljs (Recommended for Complex Splits)
1. Configure shadow-cljs.edn
Use :module-type :esm for native ES modules (browser) or :commonjs for Node/SSR. shadow-cljs automatically resolves the dependency graph; you only declare entry points.
{:builds
{:app {:target :browser
:output-dir "public/js"
:modules
{:base {}
:admin {:entries [app.admin.core]}
:charts {:entries [app.charts.dashboard]}}
:devtools {:loader-mode :eval}}}}
2. Use Dynamic import() in Code
Shadow-cljs translates (:require ["@module/admin" :as admin]) into a dynamic import() that returns a Promise. No cljs.loader needed.
(ns app.router
(:require ["@module/admin" :as admin]))
(defn navigate-to-admin []
(-> (admin/load) ; returns a Promise
(.then (fn [mod] (mod/init)))
(.catch (fn [err] (js/console.error "Admin load failed" err)))))
3. Build
npx shadow-cljs release app
Run from project root. Outputs hashed files (e.g., public/js/app.base.abc123.js) and a manifest.edn per module.
Expected Checks
- Base module purity:
grep -r "app.admin\|app.charts" out/base.jsshould return nothing. Base must only containcljs.coreand shared deps. - Module size budget: Each feature module
< 200KB gzipped. Check withgzip -c out/admin.js | wc -c. - Network tab verification: Open browser DevTools → Network, trigger the lazy route (e.g., click "Admin"). Only the corresponding chunk (
admin..js) should fetch with 200 OK. - Load callback / Promise resolution: Confirm the success callback fires or the dynamic import Promise resolves. Add a
console.login the callback to verify.
Recovery Options
Runtime Load Failure
cljs.main: Use the:errorcallback incljs.loader/loadto log to your monitoring service, render a fallback UI (e.g., "Admin panel unavailable, retry?"), and optionally retry with exponential backoff.shadow-cljs: Attach.catch()to the dynamic import Promise. Same fallback strategy applies.
Build-Time Misconfiguration
Circular dependencies or missing entries produce clear compiler errors. As an immediate unblock, revert to a single-module build by removing :modules (or setting :modules :none in shadow-cljs) and rebuild.
Critical Cautions
- Advanced optimizations rename symbols: Annotate entry-point functions with
^:exportor provide externs so the module loader can find them after minification. - Don't mix module systems:
cljs.loaderexpects CommonJS-stylegoog.module.get;shadow-cljswith:module-type :esmemits ES dynamicimport(). Pick one strategy per build. - Shared dependencies duplicate if not in
:base: Large libs (Reagent, Chart.js) must be required by a namespace that ends up in:base(e.g., aapp.sharedns listed in:baseentries) or they will appear in every feature module. Audit withshadow-cljs build-reportorcljs.build.api/print-module-graph. - Cache busting:
shadow-cljsadds content hashes by default. Withcljs.mainyou must implement your own (e.g.,:output-dir "out/[hash]"via a wrapper script). - Node/SSR targets: Require
:module-type :commonjsand a CommonJS loader. ES modules in Node need.mjsor"type": "module"inpackage.jsonand breakcljs.loader.
Automated Regression Guard
Add a CI step that parses shadow-cljs build-report.json (or manifest.edn for cljs.main) and fails if any module exceeds a size budget (e.g., 150KB gzipped). Example using jq:
npx shadow-cljs run shadow.cljs.build-report --json > report.json
MAX=153600 # 150KB
for size in $(jq '.modules[].size' report.json); do
if [ $size -gt $MAX ]; then
echo "Module exceeds budget: $size bytes"
exit 1
fi
done
Verification Checklist
- Build with
:optimizations :noneand inspect generated JS:base.jscontainsgoog.provide('cljs.core')but no feature namespaces; feature modulesgoog.require('cljs.core')and their entry namespace. - Trigger lazy load in browser, confirm only the relevant chunk loads.
- Simulate network failure (DevTools → Offline) and verify fallback UI appears and retry works.
- Validate source maps: production stack traces should map to original
.cljsfiles for each module (shadow-cljsemits.mapper module by default).
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.