Skip to content

Research: SDK bundle size — what's possible, what's worth doing#2183

Draft
mattheworiordan wants to merge 13 commits intomainfrom
research/sdk-size-experiments
Draft

Research: SDK bundle size — what's possible, what's worth doing#2183
mattheworiordan wants to merge 13 commits intomainfrom
research/sdk-size-experiments

Conversation

@mattheworiordan
Copy link
Member

@mattheworiordan mattheworiordan commented Mar 14, 2026

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:

  • Error message centralisation — turns out only ~5 KB of the 184 KB bundle is error strings (3%), and 87 of 96 are unique. A lookup table saves nothing. I considered going further — stripping messages entirely and relying on error codes — but that would hurt developer experience for negligible savings. Not worth touching.
  • Logger string constants — only 2.6 KB of mostly-unique strings
  • Code deduplication — gzip already handles repeated patterns well
  • Property mangling on the current codebase — not enough _-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

Change Savings (minified) Savings (gzip) Effort
Log stripping on ably.min.js -19.4 KB (-10.5%) -3.3 KB (-6.4%) 1-line change
Remove msgpack from browser bundle -8 KB (-4.3%) -1.9 KB (-3.8%) 4-file change
Switch minifier to swc -3.4 KB (-1.8%) -2.1 KB (-4.2%) Build pipeline
Optimise msgpack.ts + bufferutils.ts -4.5 KB (-36.4%) -1.1 KB (-27.9%) Done (157 new tests)
Enable Brotli on CDN N/A -7.1 KB (-14%) Infra config
Property mangling (long-term) -33 KB (-19%) -3.8 KB (-8%) Codebase convention

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.com directly. Good news: gzip IS enabled (50 KB transfer). Bad news: Brotli is not — requests for Accept-Encoding: br get 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:

  • Getting started docs: no mention
  • Setup/install docs: no mention
  • SDK listing page: no mention
  • No dedicated docs page exists
  • GitHub README: hidden behind a collapsed <details> tag

We 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:

  1. Log stripping: just do it, 1-line change, -19 KB, zero risk
  2. Modular discoverability: the biggest real-world win, needs docs/DX attention
  3. msgpack removal: breaking change, but ~22 accounts, graceful fallback
  4. swc minifier: build pipeline improvement, source maps working
  5. msgpack.ts + bufferutils.ts optimisation: already done, just needs review
  6. Property mangling: long-term, but the PoC on ConnectionManager.ts proves it works

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.

mattheworiordan and others added 13 commits March 14, 2026 09:37
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>
@coderabbitai
Copy link

coderabbitai bot commented Mar 14, 2026

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 4fb5f7d7-ed8d-4bc7-a279-acd0631f7761

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch research/sdk-size-experiments
📝 Coding Plan
  • Generate coding plan for human review comments

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

1 participant