Designing for a Lighter Web
Back in 2011 most JavaScript web developers were still carefully curating their dependencies and manually filling in gaps in browser functionality. ClojureScript differentiated itself by offering a richer set of tools (cljs.core) and a large standard library (Google Closure Library) counter-balanced by state-of-the-art tree-shaking via the Google Closure Compiler.
But in a few short years that distinction became less clear as the industry collectively pivoted to larger and larger client-centric web applications built upon ever more complex dependency graphs and the tooling to make it work (sorta). Whether you wrote JavaScript, TypeScript, or ClojureScript, or used framework X or Y, these decisions amounted to choosing the color of the deck chair. When I started my current job last year, the company product required megabytes of bundled JavaScript and took seconds to achieve an interactive state.
A year and a rewrite later, our application can present 100X more data instantly yet we deliver only ~30K brotli of client code. Implementing UI features is faster as we don't need to wade through component APIs unaligned to our narrower use cases. We don't need to manage state in two different locations. We haven't spent any time designing and building a client API that's constantly evolving (polite for breaking) to fit business needs. We're certainly not fiddling with Node dependencies or babysitting the build tooling. There's still an incredible amount of work to be done, but a good chunk of incidental complexity and busy work vanished.
I don't believe that our experience is an isolated one. The growing popularity of HTMX, DataStar (we use this), and related approaches that look more like 2006 than 2026, show that developers who have retained their keen senses in an AI-pilled age are turning away from the current excesses and are out in the wilderness foraging for something simpler. Perhaps somewhere in that wilderness lie the ruins of a civilized age.
Start with a convivial use case
I think one mark of a great tool is its suitability for convivial usecases rather than industrial ones. Years before I was paid to write ClojureScript, I just used it for fun on my blog. The gzipped bundle sizes of my ClojureScript examples were either smaller or comparable to minified and gzipped jQuery. These days that's positively slim, but I wanted to narrow the gap relative to handwritten, dependency-less JavaScript.
One idea I had floating around for a long time was a ClojureScript compiler mode that swapped in the simpler copy-on-write data structures from the original 2011 release rather than the sophisticated persistent datastructure implementations we faithfully ported from Clojure Java source files.
Last summer while pondering HTMX, DOM morphing, and DataStar, I started jotting down implementation notes in my decade old ClojureScript org file and tweaking the ClojureScript compiler late into the night.
Unraveling the knots
It's only a half-joke that Clojure is a bunch of data structures with a programming language attached. In the early days, Clojure made a number of clever optimizations around these core data structures, and these optimizations were often strongly connected. That is, they don't play well with tree-shaking. For example, if you use cljs.core/PersistentVector, well then you need cljs.core/TransientVector and cljs.core/ChunkedSeq and cljs.core/ChunkedCons and, and …
So writing an innocuous [] actually pulls in quite a few things. For server code this isn't very interesing, but on the client these interconnections get in the way of shaving off a few more kilobytes. ClojureScript had a lower bound of about 18 kilobytes brotli. Could we get that down to 12K? 8K? Less?
The other knot to untie in the code base was printing, because REPL stands for Read-Eval-Print-Loop. Because printing knows about every single data type including internal structures in some cases, (println "Hello world!") all by itself will pull in the entire standard library because, again, Clojure is a bunch of data structures with a programming language attached.
Ouch.
Art of the Metaobject Protocol
I like to think that Clojure took a page from the Art of the Metaobject Protocol. Rather than design a Lisp on concrete datatypes as is traditionally done, Clojure erected a language on a rich set of interfaces. Later, ClojureScript was the first dialect of the Clojure programming language to be written on top of Clojure protocols. The beauty of this polymorphic library design meant that :lite-mode was largely a copy-and-paste affair, no significant changes to the standard library required. Most of the effort was in updating the 2011 implementations to match the broader expectations 15 years later. A few new protocols had been added and quite a few behavioral expectations from Clojure were captured in a number of tests.
Under :lite-mode, ClojureScript simply emits constructor calls to the old data structures. Google Closure advanced compilation will see that none of the more complex implementations are used and remove them.
For the case of printing, the answer was even simpler. Just elide the printing machinery. Recursive printing of ClojureScript datastructures is just dead weight for many simpler programs. Every data structure implements toString. If the user supplies the :elide-to-string compiler flag we simply drop those implementations.
What follows is the comparison table I maintained by hand in the org mode file during development. I haven't changed anything here at all and in some cases I don't even know what I was thinking anymore. expression is the ClojureScript expression. size is the actual size of the advanced compiled output. brotli is after compression. release is the baseline (the output of the main branch after brotli).
| expression | size | brotli | release | note |
|---|---|---|---|---|
| :foo | 5K | |||
| [] | 15K | 3K | needed RSeq | |
| {} | 32K | 6K | ||
| {:foo "bar"} | 3K | surprising! | ||
| (seq {:foo "bar"}) | 28K | 6K | -seq min assumption | |
| () | 11K | |||
| #{} | 22K | |||
| (1 2 3) | 14K | |||
| (count []) | 15K | |||
| (count {}) | 19K | |||
| (conj [] 1) | 16K | |||
| (conj {} [1 2]) | 29K | |||
| (-conj {} [1 2]) | 29K | |||
| (-assoc {} :bar 2) | 23K | |||
| (-assoc [0] 0 1) | 4K | WHAT | ||
| (-dissoc {} :foo) | 22K | what I expect | ||
| (map inc (range 10)) | 27K | |||
| (->> (map inc … (drop 1))) | 28K | 6K | 19K | |
| (reduce + 0 [1 2 3 4 5]) | 18K | |||
| (merge {:foo 1} {:bar 2}) | 31K | 7K | 18K | |
| (merge {:foo 1} {:bar 2}) | 43K | 8K | 18K | w/ .toString |
| (println (merge {:foo 1} {:bar 2})) | 62K | 12K | 16K | release is 89K |
| (. HashMap -EMPTY) | 21K | reasonable |
"surprising!" and "WHAT" comments indicate cases where Google Closure Compiler could see through the data structure implementation (easier with 2011 code) and optimize almost everything away.
The broad takeaway is that the main branch was already stunning. You really can fearlessly use the ClojureScript standard library and not worry about getting a bloated output. :lite-mode gets rid of the 18K wall, but the more you use the standard library the returns gradually diminish.
But this isn't a problem. ClojureScript provides great support for interacting with JavaScript objects and arrays. For more discerning users :lite-mode + :elide-to-string guarantees that the code size won't unexpectedly explode. Part of the changes included adding a bunch of tests that verify the size of programs under the new compiler flags avoid introducing unintended tree-shaking issues to the standard library. Even if many people never use these flags, everyone benefits from the analysis that was required to achieve these results. I'd like to a draw analogy with NetBSD. Most people are not going to run NetBSD on an Amiga, but as a result NetBSD is a simple and light system everywhere.
We need a lighter web, and ClojureScript is not a bad tool to bring along the way.