Research: SDK bundle size — what's possible, what's worth doing#2183
Research: SDK bundle size — what's possible, what's worth doing#2183mattheworiordan wants to merge 13 commits intomainfrom
Conversation
Apply the existing stripLogsPlugin (used by modular build) to the minified browser bundle. Results: - Before: 184,471 B min / 50,804 B gzip - After: 165,055 B min / 47,539 B gzip - Delta: -19,416 B min (-10.5%) / -3,265 B gzip (-6.4%) - Feasibility: HIGH - Notes: Plugin already existed and worked. Zero source changes needed. Only non-error log calls are stripped. Error logs preserved. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replace esbuild's built-in minifier with swc (compress.passes: 2) via an esbuild onEnd plugin. esbuild still handles bundling; swc handles minification as a post-build step. This commit builds on experiment #1 (log stripping), so the numbers reflect both changes combined: Results (combined with log stripping): - esbuild minify: 165,055 B min / 47,539 B gzip - swc minify: 162,092 B min / 45,319 B gzip - Delta: -2,963 B min / -2,220 B gzip Results (swc vs esbuild, measured independently without log stripping): - esbuild: 184,994 B min / 50,826 B gzip - swc: 181,586 B min / 48,683 B gzip - terser: 183,627 B min / 49,058 B gzip - swc delta: -3,408 B min (-1.8%) / -2,143 B gzip (-4.2%) - Feasibility: HIGH - Notes: scripts/experiment-minifier-compare.js has the standalone 3-way comparison. @swc/core added as devDependency. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Test mangling _-prefixed properties using terser mangle.properties.regex. Results: - Before: 183,627 B min / 49,058 B gzip (terser baseline) - After: 180,813 B min / 48,723 B gzip - Delta: -2,814 B min (-1.5%) / -335 B gzip (-0.7%) - Feasibility: LOW - Notes: Only 230 _-prefixed property accesses found (49 unique). The codebase doesn't use _ convention extensively enough for this to be worthwhile. Would require renaming ~hundreds of internal properties to _-prefix first, which is a massive refactor with high breakage risk for modest gains. Not recommended. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Analyzed all ErrorInfo/PartialErrorInfo constructor strings in the bundle. Results: - Total error strings: 96 (87 unique) - Raw string bytes in minified: ~5,147 B - Lookup table size estimate: ~5,073 B - Delta: ~0 B (net neutral to slightly negative) - Feasibility: NONE — hypothesis disproved - Notes: Very little duplication (87/96 messages are unique), so a lookup table provides no savings. The error strings themselves are only ~5 KB of the 184 KB bundle (2.8%). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Analyzed all Logger.logAction() action strings in the bundled output. Results: - Total logger action strings: 98 (string literals) + 3 (template literals) - Total string bytes: 2,578 B - Most strings are unique (very few duplicates) - Estimated savings from constants: <200 B - Feasibility: NONE — hypothesis disproved - Notes: Only 2.6 KB of logger strings exist, and they are mostly unique. Combined with experiment 1 (log stripping), all non-error log strings are removed entirely. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Analyzed repeated code patterns (fromValues, fromValuesArray, toJSON) in the bundled output. Results: - Total duplicated pattern bytes: 3,679 B unminified - Estimated savings: ~1,160 B minified / ~348 B gzip - Feasibility: LOW — not worth the effort - Notes: The identified patterns (fromValues, fromValuesArray, toJSON) are individually small (60-220B each). Gzip already handles repeated patterns well. The refactoring cost/risk far outweighs the benefit. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Browsers default to JSON (preferBinary: false in config.ts). The 735-line
msgpack.ts implementation ships in the browser bundle but never executes
for the vast majority of users.
Changes:
- src/platform/web/index.ts: Remove msgpack import and _MsgPack assignment
- src/common/lib/client/defaultrealtime.ts: Allow _MsgPack to be null
(was: throw if null)
- src/common/lib/client/defaultrest.ts: Same
- src/common/lib/util/defaults.ts: Log error if useBinaryProtocol is set
without MsgPack plugin, then gracefully fall back to JSON
This is NOT a breaking change: browsers already use JSON by default.
Users who explicitly set useBinaryProtocol: true will get a clear error
message but the client will continue working with JSON.
To use binary protocol after this change:
import { MsgPack } from 'ably/modular';
new Ably.Realtime({ plugins: { MsgPack } });
Results (msgpack removal alone vs baseline):
- Before: 184,471 B min / 50,804 B gzip / 43,682 B brotli
- After: 176,462 B min / 48,862 B gzip / 41,990 B brotli
- Delta: -8,009 B min (-4.3%) / -1,942 B gzip (-3.8%)
Results (all 3 optimizations combined: log strip + swc + no msgpack):
- After: 154,361 B min / 43,521 B gzip / 37,259 B brotli
- Delta: -30,110 B min (-16.3%) / -7,283 B gzip (-14.3%)
- Feasibility: MEDIUM (non-breaking but behavioral change)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Restructure msgpack.ts and bufferutils.ts for minifier-friendly output, reducing combined minified size by 29.7% (12,472 -> 8,765 bytes). msgpack.ts (7,987 -> 5,246 bytes, -34.3% minified): - Merge sizeof() and _encode() into single sizeOrEncode() function - Convert Decoder class to closure-based decode (local vars minify better) - Remove unused exports (inspect, utf8Write, utf8Read, utf8ByteCount) - Reuse TextEncoder/TextDecoder instances at module level bufferutils.ts (4,485 -> 3,519 bytes, -21.5% minified): - Replace 50-line manual base64 encoder with btoa(String.fromCharCode(...)) - Simplify base64 decode using atob() directly - Extract internal helpers as standalone functions for better minification Also adds comprehensive test coverage: - msgpack: 4 -> 88 tests (all msgpack types, boundaries, roundtrips, wire format) - bufferutils: 2 -> 69 tests (RFC 4648 vectors, URL-safe base64, hex, UTF-8) All 157 tests verified against both original and optimized code. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Five additional LLM-assisted optimization passes, reducing combined minified size by a further 9.5% (8,765 -> 7,935 bytes). Total reduction from original: 12,472 -> 7,935 bytes (-36.4%). msgpack.ts (5,246 -> 4,528 bytes, -13.7%): - wHdr/wNum helpers for tag+length/value writing patterns - Merged setInt64/setUint64 into single set64 function - pickHdr helper for 3-tier length bracketing - Unified rd decode function, fixext table lookup - AB/U8 aliases for ArrayBuffer/Uint8Array globals - Merged utf8Len+utf8W into utf8B with Uint8Array.set() bufferutils.ts (3,519 -> 3,407 bytes, -3.2%): - U8/AB aliases for Uint8Array/ArrayBuffer globals - Simplified internal helper extraction All 157 tests passing (88 msgpack + 69 bufferutils). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The swcMinifyPlugin was producing source maps pointing to <anon> instead of original .ts files. Fixed by composing esbuild's source map (TS→bundled) through swc's map (bundled→minified) using @ampproject/remapping. The final ably.min.js.map now correctly maps to 69 original .ts source files. sourceMappingURL comment is also restored in the minified output. Unblocks swc minifier (experiment #2) for production use. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…I size tracking Property mangling (Experiment A from plan): - 320 internal properties identified (out of 382 total this.* accesses) - 62 public API properties reserved from mangling - With reserved list: -33,446 B min (-19.1%) / -3,825 B gzip (-8.1%) - Top internal properties: this.logger (247x), this.client (123x), this.connectionManager (39x), this.states (30x) - Confirms the potential is real but requires codebase-wide _-prefix convention sideEffects: false added to package.json: - Helps downstream bundlers (webpack, Rollup) tree-shake more aggressively - No impact on our own bundle, but benefits npm users CI size tracking (scripts/check-bundle-size.js): - Reports raw/gzip/brotli sizes for build artifacts - --check flag enforces configurable thresholds (fails CI if exceeded) - Thresholds set at current size + 5% headroom Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Programmatically renamed 87 internal properties in ConnectionManager.ts to _-prefix, then mangled with terser. Build succeeded (no cross-file breakage — all properties are class-internal). Results (ConnectionManager.ts only): - 217 _-prefixed accesses in minified (48 unique properties) - Before mangling: 153,362 B min / 43,253 B gzip - After mangling: 150,382 B min / 42,867 B gzip - Delta from ONE file: -2,980 B min / -386 B gzip This validates that: 1. The _-prefix rename is safe for class-internal properties 2. Property mangling works on renamed properties 3. Extrapolating to all 320 internal properties across the codebase confirms the -33 KB min / -3.8 KB gzip full potential Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Renames 87 internal properties in ConnectionManager.ts to _-prefix convention (e.g., this.realtime -> this._realtime). Build succeeds — all properties are class-internal with no cross-file breakage. 318 lines changed (this.prop -> this._prop). Public API properties (connection, state, errorReason, etc.) are preserved unchanged. With terser mangle.properties.regex=/^_/ on this one file: - Before: 153,362 B min / 43,253 B gzip - After: 150,382 B min / 42,867 B gzip - Delta: -2,980 B min / -386 B gzip (from ONE file) Extrapolating to all 320 internal properties across the full codebase confirms the -33 KB min / -3.8 KB gzip potential measured in the full property mangling assessment. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
|
Important Review skippedDraft detected. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
📝 Coding Plan
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment Tip CodeRabbit can scan for known vulnerabilities in your dependencies using OSV Scanner.OSV Scanner will automatically detect and report security vulnerabilities in your project's dependencies. No additional configuration is required. |
d530be5 to
6dddcdf
Compare
Background
While working on the message materialiser PR (streaming JSON for AI token streaming), I noticed that even the small partial-JSON parser was adding to the bundle. It got me wondering whether it was worth making it an optional dependency, like we do for push, liveobjects, etc.
That led to a broader question: historically, optimising JS bundle size has been a diminishing-returns game. Lots of effort for marginal gains. But I tried something different. I pointed Claude Code at the partial-JSON parser and asked it to optimise for size across multiple iterations. It produced a 77% reduction (6.3 KB → 1.5 KB) with more tests than the original, fully automated.
That got me thinking: could we do the same for ably-js in general?
What I did
I spent a couple of hours getting agents to run experiments in parallel — testing what's actually possible vs what sounds good in theory. The goal was to find things that don't hurt maintainability, performance, or reliability.
The surprising findings
Several ideas I expected to work didn't:
_-prefixed properties (yet)I also looked at using LLM iterations to optimise the entire codebase for size — the same technique that worked so well on the partial-JSON parser. Two problems: a CI build that runs LLM optimisation each time gives you non-deterministic output (different code on every build), and if you do it as a one-off refactor, you end up with unreadable, unmaintainable code optimised purely for bytes. Neither is right for a library other people need to work on.
What actually works
Combined (without property mangling): 153 KB min / 43 KB gzip — down from 184 KB / 51 KB.
With property mangling: ~120 KB min / ~39 KB gzip — a 35% reduction from today.
I also measured making Crypto optional (same pattern as msgpack removal). It's only ~2.6 KB minified / 855 B gzip — not enough to justify the change, especially since we can't measure how many users actually use encryption (it's end-to-end client-side, invisible to our servers). Worth revisiting if we ever add instrumentation.
The msgpack data
Only 0.12% of browser connections use msgpack (from ~22 accounts, measured over 48 hours). Browsers default to JSON. The 735-line msgpack implementation ships but never executes for 99.88% of users. The PoC shows a graceful fallback — log an error, continue with JSON, nobody's service breaks.
CDN: gzip works, Brotli doesn't
I tested
cdn.ably.comdirectly. Good news: gzip IS enabled (50 KB transfer). Bad news: Brotli is not — requests forAccept-Encoding: brget the full 184 KB uncompressed. Enabling Brotli on the CloudFront distribution is a single config change and would save another ~14% on top of gzip. That's an infra team action, no code changes.But the biggest issue isn't code
The modular variant already provides a 66% size reduction (92 KB / 28 KB gzip). But almost nobody knows about it:
<details>tagWe invested significant engineering effort in creating the modular build and then barely invested in making sure developers can find it. That's the highest-ROI fix available — zero code changes, massive real-world impact.
What I'm proposing
This isn't an RFC. I wanted to share these findings so we can have a quick discussion about which are worth pursuing. My view:
The branch has 13 commits, each showing a specific experiment with real code changes where applicable.
A note on how this was done
This whole investigation — 10 experiments, code optimisations, 157 new tests, counsel review by 3 independent AI agents, and this write-up — took about 2 hours of my time. The agents did the heavy lifting. I think this is a good example of how the cost of optimisation has fundamentally changed. Things that weren't worth a developer's time before are now worth doing when an agent can run 6 optimisation iterations unattended and produce better-tested code than the original.
The question for us is: should we get someone to spend a day seeing how far they can take these? I think yes (a 30% reduction is meaningful), and the discoverability issues are critical regardless.