clarity.js is the script that runs on websites using Clarity and captures the behavioral data behind Clarity insights. It has to load alongside everything else a site sends to the browser, which makes its size important. A lighter script means less JavaScript for the browser to download, which reduces Clarity’s footprint on the billions of pages it runs on.
The question was how to make it smaller without compromising what the script does or the range of websites it needs to support.
To find those savings, we turned the work into an automated experiment. pi, an AI coding agent, used autoresearch to make a change, build the script, measure the compressed output, run the test suite, and then keep or revert the change based on the result.
After more than 50 experiments, clarity.js was 1,257 gzip bytes smaller, a 4.5% reduction in size, with no need to reduce its functionality. Just as importantly, the process revealed something unexpected: many changes that look more efficient in source code don’t actually produce a smaller script once it’s compressed.
That’s where the engineering gets interesting.
Gzip and Clean Code Don’t Get Along
Gzip finds repeated byte sequences and replaces them with short references. So repetitive code is basically free since gzip has already seen that pattern and stores it cheaply. But every new function name, every novel code pattern, every unique string? That’s a fresh dictionary entry, and it costs real bytes.
We Tried Extracting Helpers. It Kept Backfiring.
We had four identical blocks of coordinate-tracking code, six lines each, copy-pasted across switch cases. So we extracted a coordinates() helper. Raw size dropped by 264 bytes.
Gzipped size went up by 23 bytes.
Gzip was already compressing those four identical blocks down to almost nothing. The helper introduced a new function name, a new call signature, and four new call sites; all unique patterns that gzip couldn’t compress efficiently.
We tried this in a few other places and it kept going the wrong way:
| What We Tried | Gzip Δ |
| Shared coordinates() helper | +23 😬 |
| dim() helper for width/height | +13 😬 |
| Hoist shared arrays to module scope | +2 😬 |
| ||= logical assignment operator | +3 😬 |
The ||= one is worth calling out. It’s literally shorter syntax, but it’s a rare byte pattern in the output. The old x = x || defaultValue pattern shows up all over the place, so gzip has it in its dictionary already. The “modern” version is actually worse.
What Actually Worked
Namespace Import Elimination (-444 gzip, 35% of total savings)
This was the big one. When you write import * as module and then use that namespace as a value (stick it in an array, pass it around) Rollup has to create a runtime object wrapping all the module’s exports. If you only access properties directly (module.start()), Rollup just inlines the calls and no wrapper is needed.
We had this:
import * as diagnostic from “@src/diagnostic”;
const modules = [diagnostic, layout, interaction, performance, dynamic];
Rollup was generating five namespace wrapper objects. We switched to named imports:
import { start as diagStart, stop as diagStop } from “@src/diagnostic”;
const modules = [{ start: diagStart, stop: diagStop }, …];
-444 gzip bytes across three files. data/index.ts had 13 namespace imports in a dispatch table, and just that one file accounted for 324 bytes.
Not all import * is bad, though. If you’re only doing property access, Rollup handles it fine. You only need to worry when the namespace itself gets passed around as a value.
Removing Entire Functions (-84 gzip)
Extracting helpers hurts gzip, but going the other direction (removing a function entirely) helps a lot. You’re removing a unique name, a unique signature, and a unique block of logic from the gzip dictionary.
compress.ts had a 12-line function that manually reads chunks from a ReadableStream. The Response API does the same thing in one line:
// 12 lines of manual stream reading became:
return new Uint8Array(await new Response(stream).arrayBuffer());
-44 gzip bytes. We also removed a parseSignals() wrapper that just called JSON.parse() inside a try/catch. The caller already had its own try/catch. That was another 20 bytes.
Terser Options (-301 gzip)
We tested every terser compress and mangle option we could find, one at a time. Most did nothing. The ones that worked: passes: 3, hoist_vars: true, and freeze: false on Rollup’s IIFE outputs.
These didn’t help us, but your mileage may vary: passes: 4, unsafe_math, unsafe_undefined, unsafe_arrows, reduce_vars, wrap_iife, ecma: 2017 in compress. Property mangling was the only one that caused real problems because minified names collided with DOM property names.
Template Literals → Concatenation (-110 gzip)
Our target is ES5, so TypeScript compiles `${a}${b}` into “”.concat(a).concat(b). Writing a + b instead compiles to just a+b, no method calls. We went through the whole codebase and switched them all.
Large Block Deduplication (-109 gzip)
Small helper extraction hurts gzip, but really large unique blocks (20+ lines) are a different story. They’re too big for gzip to handle efficiently. baseline.ts had 27 lines that copied buffer properties one by one. Object.assign({}, buffer) replaced the whole thing. -109 gzip bytes.
The Biggest Win We Couldn’t Ship
Bumping the TypeScript target from ES5 to ES2017 removed the __awaiter/__generator polyfills, those multi-line helpers TypeScript injects to make async/await work in ES5. That plus ES2020 (optional chaining, nullish coalescing) saved 2,308 gzip bytes. Almost double everything else combined.
Ultimately, we couldn’t ship this optimization because of compatibility requirements.
Clarity runs on all kinds of websites, including Angular apps with Zone.js. Zone.js patches Promise and expects the ES5 generator-based async/await output. When we emit native async/await, Zone.js can’t intercept it, and Angular’s change detection breaks silently.
Why This Works as an Experiment Loop
Bundle size optimization is a lot of small experiments. Each one saves maybe 5-50 bytes, and you don’t know which changes will actually help until you measure the gzipped output.
Each build-measure cycle takes about 35 seconds. The agent can test 100 ideas in under an hour, with test-suite checks after every one. It also tries things we probably wouldn’t have bothered with, like systematically testing 15 terser options, or finding out that ||= makes gzip worse.
If You’re Doing This Yourself
- Check your namespace imports. If import * as X is used as a value (in arrays, passed to functions), convert to named imports. Probably your biggest win.
- Test minifier options one at a time. Most do nothing. The ones that work are free savings.
- Remove whole functions when you can. Wrappers, single-use helpers, manual reimplementations of platform APIs.
- Don’t extract small helpers to “clean up” repeated code. Gzip already compresses the repetition.
- Measure gzipped size, not raw size. We had changes that saved hundreds of raw bytes and increased the actual download.
- Automate it. 5-byte improvements aren’t worth a human’s time, but they’re worth 35 seconds of automated experimentation.
