Instantly share code, notes, and snippets.
Save mizchi/4f9293a9742deed7ce6c26a05bbd0727 to your computer and use it in GitHub Desktop.
This document analyzes the bundle size of MoonBit JavaScript output, identifying the main contributors to code size and potential optimization opportunities.
Note: The analyzed output is after terser DCE (Dead Code Elimination) with compress enabled. Unused code has already been removed.
https://github.com/mizchi/js.mbt/blob/main/src/examples/aisdk/main.mbt
Example: aisdk (AI SDK streaming example)
- Source: 24 lines of MoonBit (
src/examples/aisdk/main.mbt) - Output: 115,183 bytes (raw JS)
main.mbt:
asyncfnmain {// Load environment variables@dotenv.config()|>ignoreprintln("[ai] Starting stream...")// Start streamingletstream=@ai.stream_text(model=@ai.anthropic("claude-sonnet-4-20250514"),prompt="Count from 1 to 10, one number per line", )// Iterate over text chunkslettext_iter=stream.text_stream()letstdout=@process.stdout()whiletrue {matchtext_iter.next() {Some(chunk)=>stdout.write(chunk)|>ignoreNone=>break } }println("\n\n[ai] Stream complete!")// Get final usage statsletusage=stream.usage()println("Tokens used:\{usage.total_tokens.to_string()}")}
moon.pkg.json:
{"is-main":true,"import": ["mizchi/js","mizchi/js/node/process","mizchi/js/node/tty","mizchi/js/npm/ai","mizchi/js/npm/dotenv","moonbitlang/async" ]}| Category | Size | Percentage |
|---|---|---|
| mizchi$js bindings (excluding vtable) | 48,849 bytes | 42.4% |
| vtable inline expansion | 33,203 bytes | 28.8% |
| MoonBit runtime (core+async) | 19,875 bytes | 17.3% |
| Others (helpers/types) | 9,545 bytes | 8.3% |
| main.mbt (user code) | 2,680 bytes | 2.3% |
| Unclassified | 1,031 bytes | 0.9% |
| Component | Size | Notes |
|---|---|---|
| moonbitlang$core | 15,010 bytes | Map, Deque, Hasher, etc. |
| moonbitlang$async | 4,865 bytes | Coroutine scheduler |
| Component | Size | Notes |
|---|---|---|
| mizchi$js$npm (ai, dotenv) | 45,195 bytes | NPM package bindings |
| mizchi$js (core) | 34,534 bytes | Core JS interop |
| mizchi$js$node (process) | 2,323 bytes | Node.js bindings |
| Type | Count | Size per instance | Total |
|---|---|---|---|
| JsImpl vtable (13 methods) | 48 | ~558 bytes | 26,799 bytes |
| PropertyKey vtable | 100 | ~64 bytes | 6,404 bytes |
The JavaScript interop layer dominates bundle size:
- Binding logic: 48,849 bytes (42.4%)
- vtable inline expansion: 33,203 bytes (28.8%)
The core runtime includes:
- Data structures for async (Map, Deque)
- Hash functions
- Coroutine scheduler
24 lines of MoonBit compiles to 2,680 bytes, but the remaining 97.7% is runtime and bindings.
The binding layer uses a trait with default method implementations (src/impl.mbt):
pub(open)traitJsImpl {to_any(Self)->Any=_get(Self,&PropertyKey)->Any=_set(Self,&PropertyKey,&JsImpl)->Unit=_call(Self,&PropertyKey,Array[&JsImpl])->Any=_call0(Self,&PropertyKey)->Any=_call1(Self,&PropertyKey,&JsImpl)->Any=_call2(Self,&PropertyKey,&JsImpl,&JsImpl)->Any=_call_throwable(Self,&PropertyKey,Array[&JsImpl])->AnyraiseThrowError=_call_self(Self,Array[&JsImpl])->Any=_call_self0(Self)->Any=_call_self_throwable(Self,Array[&JsImpl])->AnyraiseThrowError=_delete(Self,&PropertyKey)->Unit=_hasOwnProperty(Self,&PropertyKey)->Bool=_}implJsImplwithget(self,key :&PropertyKey)->Any {ffi_get(self.to_any(),key.to_key()|>identity)}implJsImplwithset(self,key :&PropertyKey,val :&JsImpl)->Unit {ffi_set(self.to_any(),key.to_key()|>identity,val.to_any())}
The FFI layer (src/ffi.mbt) provides simple JavaScript operations:
extern"js"fnffi_get(obj :Any,key :String)->Any=#| (obj, key) => obj[key]extern"js"fnffi_set(obj :Any,key :String,value :Any)->Unit=#| (obj, key, value) => { obj[key] = value }extern"js"fnffi_call0(obj :Any,key :String)->Any=#| (obj, key) => obj[key]()extern"js"fnffi_call1(obj :Any,key :String,arg1 :Any)->Any=#| (obj, key, arg1) => obj[key](arg1)
When using trait objects (&JsImpl), the compiler generates a vtable for each call site.Every JS value operation creates a vtable object inline:
// Current: 558 bytes per instance{self:value,method_0:mizchi$js$$JsImpl$to_any$9$,method_1:mizchi$js$$JsImpl$get$9$,method_2:mizchi$js$$JsImpl$set$9$,// ... 10 more methodsmethod_12:mizchi$js$$JsImpl$hasOwnProperty$9$}
This 13-method vtable is expanded inline 48 times.
Example from generated code:
// A simple promise.then().catch() generates ~400 bytes:mizchi$js$$JsImpl$call1$9$(mizchi$js$$JsImpl$call1$15$(self,{self:"then",method_0:mizchi$js$$PropertyKey$to_key$3$},{self:_cont,method_0: ...,method_1: ..., ...,method_12: ...}),{self:"catch",method_0:mizchi$js$$PropertyKey$to_key$3$},{self:_err_cont,method_0: ...,method_1: ..., ...,method_12: ...});
Simple string keys are wrapped in objects:
// Current: 64 bytes{self:"model",method_0:mizchi$js$$PropertyKey$to_key$3$}// Could be: 7 bytes"model"
Share vtable objects instead of inline expansion:
// Define onceconstJsImpl$vtable$9={method_0:mizchi$js$$JsImpl$to_any$9$,method_1:mizchi$js$$JsImpl$get$9$,// ...};// Use reference{self:value, ...JsImpl$vtable$9}// or{self:value,$v:JsImpl$vtable$9}
Estimated savings: ~30,763 bytes (26.7%)
For simple property access/calls, generate direct code:
// Currentmizchi$js$$JsImpl$get$9$(obj,{self:"key",method_0: ...})// Optimizedobj["key"]
Pass strings directly instead of wrapping:
// Current{self:"key",method_0:mizchi$js$$PropertyKey$to_key$3$}// Optimized"key"
- vtable sharing - Highest impact, 28.8% of bundle
- PropertyKey simplification - 5.6% of bundle
- Direct FFI generation - Reduces binding code complexity
- Runtime tree-shaking - Remove unused Map/Set operations
# Build examplesmoon build --target js# Generate size report with output files./scripts/check_sizes.ts --output-files# Run analysis scriptnode tmp/analyze_size.js
scripts/check_sizes.ts- Bundle size measurement tooltmp/check-sizes/*/- Output files for inspection.bundle_size_baseline.json- Size baseline for comparison