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:
async fn main {
// Load environment variables
@dotenv.config() |> ignore
println("[ai] Starting stream...")
// Start streaming
let stream = @ai.stream_text(
model=@ai.anthropic("claude-sonnet-4-20250514"),
prompt="Count from 1 to 10, one number per line",
)
// Iterate over text chunks
let text_iter = stream.text_stream()
let stdout = @process.stdout()
while true {
match text_iter.next() {
Some(chunk) => stdout.write(chunk) |> ignore
None => break
}
}
println("\n\n[ai] Stream complete!")
// Get final usage stats
let usage = 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) trait JsImpl {
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]) -> Any raise ThrowError = _
call_self(Self, Array[&JsImpl]) -> Any = _
call_self0(Self) -> Any = _
call_self_throwable(Self, Array[&JsImpl]) -> Any raise ThrowError = _
delete(Self, &PropertyKey) -> Unit = _
hasOwnProperty(Self, &PropertyKey) -> Bool = _
}
impl JsImpl with get(self, key : &PropertyKey) -> Any {
ffi_get(self.to_any(), key.to_key() |> identity)
}
impl JsImpl with set(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" fn ffi_get(obj : Any, key : String) -> Any =
#| (obj, key) => obj[key]
extern "js" fn ffi_set(obj : Any, key : String, value : Any) -> Unit =
#| (obj, key, value) => { obj[key] = value }
extern "js" fn ffi_call0(obj : Any, key : String) -> Any =
#| (obj, key) => obj[key]()
extern "js" fn ffi_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 methods
method_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 once
const JsImpl$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:
// Current
mizchi$js$$JsImpl$get$9$(obj, { self: "key", method_0: ... })
// Optimized
obj["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 examples
moon build --target js
# Generate size report with output files
./scripts/check_sizes.ts --output-files
# Run analysis script
node tmp/analyze_size.jsscripts/check_sizes.ts- Bundle size measurement tooltmp/check-sizes/*/- Output files for inspection.bundle_size_baseline.json- Size baseline for comparison