Artikel mendalam tentang teknologi yang membentuk masa depan.

Saat WebAssembly Tidak Lebih Cepat dari JavaScript

WebAssembly tidak selalu lebih cepat dari JavaScript. Benchmark menunjukkan kapan TypeScript mengalahkan WASM dan mengapa biaya lintas batas merusak performa.

Pelari terhenti di pos pemeriksaan perbatasan sementara pelari lain melesat bebas melewatinya

Bulan lalu saya menghabiskan hari Sabtu untuk menulis ulang parser metadata gambar dari Rust-WASM ke TypeScript. Versi WASM sudah berjalan di production selama delapan bulan. Sebenarnya tidak ada masalah. Tidak ada yang komplain. Tapi saya terus memelototi performance trace kami dan ada yang mengganjal: parser WASM itu ternyata lebih banyak menghabiskan waktu untuk memindahkan data melewati batas JS-WASM ketimbang benar-benar mem-parsing. Jadi saya tulis ulang. Murni TypeScript, tanpa WASM. Hasilnya 38% lebih cepat untuk median payload kami. Saya tidak menulis algoritma yang lebih bagus. Saya cuma berhenti membayar pajak lintas batas.

Pengalaman itu bikin cara pandang saya berubah. Dulu saya termasuk developer yang menganggap WebAssembly itu pasti opsi yang lebih cepat. Ternyata anggapan itu salah lebih sering daripada yang kita kira.

Mitos 'WASM Selalu Lebih Cepat' Perlu Dihapus

Model mental yang kebanyakan developer pegang kurang lebih begini: bahasa yang dikompilasi bisa dibawa ke WASM, WASM jalan mendekati kecepatan native, jadi WASM pasti lebih cepat dari JavaScript. Setiap langkahnya kurang lebih benar. Tapi kesimpulannya tidak nyambung, karena mengabaikan biaya di sekitar komputasinya: memasukkan data, mengambil hasil, dan overhead menjalankan dua runtime sekaligus.

Mesin JavaScript itu gila bagusnya. V8, SpiderMonkey, dan JavaScriptCore merepresentasikan puluhan tahun kerja optimasi yang didanai beberapa perusahaan terkaya di planet ini. Mereka melakukan speculative JIT compilation, inline caching, escape analysis, dan hidden class transition. Untuk banyak beban kerja, terutama pola yang berat di string, objek, dan callback seperti yang umum di aplikasi web, mesin JS modern menghasilkan machine code yang mengejutkan dekat dengan hasil compiler statis.

WASM tidak dapat optimasi semacam itu. Ia dikompilasi AOT. Yang kamu kirim itulah yang jalan. Ini bagus untuk prediktabilitas, tapi artinya WASM tidak bisa beradaptasi dengan pola runtime seperti yang bisa dilakukan JIT.

Masalah Lintas Batas: Mati Karena Ribuan Panggilan

Ini poin utamanya. Setiap panggilan dari JavaScript ke WASM (atau sebaliknya) punya overhead. Engine harus marshal data, memvalidasi tipe, dan berpindah konteks eksekusi. Satu kali lintas batas itu murah, sekitar beberapa mikrodetik. Tapi beban kerja yang melintasi batas ribuan kali per operasi bakal habis karena overhead ini.

Saya sering melihat pola ini: tim menulis parser, transformer, atau validator dengan Rust, dikompilasi ke WASM, lalu dibungkus glue code JavaScript yang memanggil WASM untuk setiap node, setiap token, setiap langkah. Core WASM-nya mungkin sangat cepat kalau berdiri sendiri, tapi glue code-nya menjadikannya jalan tol yang bayar di setiap gerbang.

// The toll road pattern — looks clean, performs terribly
const wasmParser = await initParser();
const ast = wasmParser.parse(source);          // JS -> WASM
for (const node of wasmParser.walkTree(ast)) { // WASM -> JS per node
if (matchesRule(node)) {                     // JS land
wasmParser.transform(node, newValue);      // JS -> WASM per match
}
}
// A 500-line document generates ~10,000 boundary crossings.
// At 2μs each, that's 20ms of pure overhead before any real work.
// Same logic, pure TypeScript — zero boundary tax
const ast = parse(source);
for (const node of walkTree(ast)) {
if (matchesRule(node)) {
transform(node, newValue);
}
}
// V8 JIT-compiles the hot loop. Total time: often under 5ms.

Saya sudah benchmark skenario persis seperti ini di codebase nyata. Versi WASM memproses batch 100 file konfigurasi dalam 340ms. Port TypeScript-nya selesai dalam 195ms. Bukan karena TypeScript bahasa yang lebih cepat, karena memang tidak, tapi karena pekerjaannya tidak pernah keluar dari satu konteks eksekusi.

Kenapa String Menghancurkan Performa WASM

WASM beroperasi di linear memory, sebuah buffer datar berisi byte. String JavaScript itu cerita yang sama sekali berbeda. Mereka adalah objek yang dikelola engine, disimpan dalam format internal khusus (Latin1, UTF-16, rope, cons string). Memindahkan string dari JS ke WASM berarti meng-encode ke UTF-8, mengalokasikan ruang di linear memory, lalu menyalin byte-nya. Mengambil string kembali berarti menyalin lagi dan men-decode.

Untuk kode yang banyak hitung-hitungan angka, ini tidak masalah. Kamu kirim TypedArray, dapat angka balik. Tapi untuk apa pun yang sering menyentuh string, seperti parser, template engine, validator, dan serializer, kamu terus membayar pajak encode-salin-proses-salin-decode untuk setiap potongan string.

// What actually happens when you pass a string to WASM
// (wasm-bindgen generates code like this behind the scenes)
function pushStringToWasm(s: string): [ptr: number, len: number] {
const encoded = new TextEncoder().encode(s); // allocate + encode
const ptr = wasm.__wbindgen_malloc(encoded.length);
new Uint8Array(wasm.memory.buffer).set(encoded, ptr); // copy
return [ptr, encoded.length];
}
function pullStringFromWasm(ptr: number, len: number): string {
const bytes = new Uint8Array(wasm.memory.buffer, ptr, len); // view
return new TextDecoder().decode(bytes.slice()); // copy + decode
}
// A parser that handles 3,000 string fragments per document
// runs this cycle 6,000 times (in + out). That adds up.

Saya profiling parser WASM kami dan menemukan bahwa 31% total waktu eksekusi habis untuk serialisasi string. Bukan parsing. Bukan membangun tree. Cuma memindahkan string bolak-balik melewati batas. Versi TypeScript menghapus seluruh kategori kerja itu.

Kalau hot path kamu berat di string, kemungkinan besar WASM justru membuatnya lebih lambat, bukan lebih cepat. Overhead serialisasinya nyata dan cepat menumpuk.

Kompilasi JIT: Keunggulan Performa yang Jarang Dibahas

Model AOT di WASM memberimu performa yang prediktabel dan konsisten. Itu bagus untuk beberapa kasus. Tapi model JIT JavaScript berarti V8 mengamati kodemu saat berjalan, lalu menghasilkan machine code yang dioptimalkan sesuai data yang sebenarnya mengalir. Seiring waktu, kode hasil JIT bisa cepat sekali.

Ambil contoh object shape specialization. Kalau kamu memproses array objek yang semuanya punya properti sama dengan urutan sama, V8 mendeteksi pola monomorphic itu dan menghasilkan machine code yang mengakses properti lewat offset memori tetap, tanpa lookup hash table, tanpa pengecekan tipe. Pada dasarnya performa seperti C struct dari JavaScript yang dinamis.

// V8 loves this pattern — all objects share one hidden class
interface Token {
kind: number;   // numeric enum, not string
start: number;
end: number;
flags: number;
}
// Flat array of uniform objects = V8's happy place
const tokens: Token[] = [];
for (let i = 0; i < source.length; ) {
const kind = classifyChar(source.charCodeAt(i));
const start = i;
i = scanToken(source, i, kind);
tokens.push({ kind, start, end: i, flags: 0 });
}
// After a few hundred iterations, V8 compiles this to
// machine code with fixed-offset property access. Fast.

WASM tidak bisa melakukan ini. Karakteristik performanya sudah tetap saat kompilasi. Untuk loop numerik yang ketat dan bisa diprediksi, itu tidak masalah, karena compiler Rust sudah mengoptimasinya habis-habisan. Tapi untuk kode yang berantakan, polimorfik, dan penuh callback yang menjadi ciri sebagian besar aplikasi web, kemampuan JIT untuk melakukan spesialisasi saat runtime adalah keunggulan nyata.

Ukuran Bundle dan Cold Start: Metrik yang Sering Terlupakan

Throughput bukan satu-satunya metrik performa. Pengguna merasakan waktu muat, latensi interaksi, dan waktu sampai hasil pertama muncul. WASM punya biaya nyata di ketiganya.

  • Modul Rust WASM dengan wasm-bindgen biasanya berukuran 100KB sampai 1,5MB setelah gzip. TypeScript yang setara, sudah di-minify dan di-gzip, biasanya cuma 10 sampai 40KB.
  • Modul WASM harus dikompilasi ke native code sebelum dieksekusi. Streaming compilation membantu, tapi tetap saja itu kerja yang harus dilakukan browser.
  • Engine JavaScript melakukan parsing secara lazy, fungsi tidak dikompilasi sampai dipanggil. WASM membayar biaya kompilasi penuh di awal.
  • Code cache V8 berarti kunjungan berulang melewati parsing JS sepenuhnya. Caching kompilasi WASM sudah ada, tapi belum sematang itu.

Ini perbandingan nyata dari proyek kami. Bundle parser WASM berukuran 380KB setelah gzip. Pengganti TypeScript-nya 26KB. Di koneksi bagus sih oke-oke saja. Di koneksi 3G yang di-throttle (yang saya simulasikan untuk setiap review performa), versi WASM menambah 1,8 detik waktu muat. Itu bukan selisih kecil, itu bedanya antara pengguna yang bertahan dan yang kabur.

Cold start juga penting. Modul WASM butuh sekitar 110ms untuk dikompilasi dan diinstansiasi di ponsel kelas menengah. Versi TypeScript sudah interaktif dalam kurang dari 20ms. Untuk alat yang dibuka puluhan kali sehari, akumulasinya jadi frustrasi nyata.

Kapan WASM Benar-Benar Menang (dan Kamu Sebaiknya Pakai)

Saya tidak bermaksud bilang WASM teknologi yang buruk. Ini teknologi hebat dengan sweet spot tertentu. Masalahnya, orang memakainya di luar sweet spot itu lalu heran kenapa malah jadi lambat.

WASM menang saat komputasinya berat, lintas batasnya sedikit, dan datanya numerik. Contohnya: filter gambar yang bekerja di buffer piksel, hashing kriptografi, simulasi fisika, audio DSP, codec video. Kamu dorong potongan data besar ke linear memory, biarkan WASM menghitungnya dalam loop ketat, lalu ambil hasilnya. Satu lintasan masuk, satu lintasan keluar, banyak komputasi di tengah. Di situlah WASM bersinar.

WASM juga menang saat kamu butuh performa yang deterministik. JIT itu kuat tapi tidak bisa diprediksi. Beberapa pemanggilan pertama fungsi JS jalan secara interpreted, lalu baseline-compiled, lalu mungkin optimizing-compiled. Kalau kamu butuh latensi yang konsisten sejak invokasi pertama (game loop, audio real-time, kalkulasi keuangan), kompilasi ahead-of-time WASM memberikan itu.

Dan WASM jelas pilihan yang tepat saat kamu memport codebase native yang sudah ada. Tidak ada yang sebaiknya menulis ulang SQLite dengan JavaScript demi menghindari overhead WASM. Itu gila. Kode C yang ada merepresentasikan puluhan tahun optimasi yang tidak akan bisa kamu tiru.

Kerangka Keputusan Praktis: WASM atau TypeScript?

Setelah menjalani latihan ini di tiga proyek, saya punya checklist sederhana. Tidak sempurna, tapi sudah dua kali menyelamatkan saya dari keputusan yang salah.

  1. Hitung lintas batas. Kalau modul WASM kamu memanggil JS (atau sebaliknya) lebih dari beberapa ratus kali per operasi, TypeScript kemungkinan besar menang. Profiling dulu untuk memastikan.
  2. Lihat tipe datamu. Kebanyakan angka dan typed array? WASM cocok. Kebanyakan string, objek, dan callback? Tetap di JS.
  3. Ukur biaya startup. Kalau kode jalan saat page load atau merespons klik pengguna, overhead kompilasi WASM jadi penting. Kalau jalan di worker yang hidup lama, biaya startup tersebar dan hilang.
  4. Cek anggaran bundle. Kalau ukuran bundle kamu sudah bermasalah, menambah modul WASM 400KB pasti bikin sakit.
  5. Pertimbangkan biaya DX. WASM menambah sistem build Rust atau C++, kerumitan source map, dan debugging yang lebih sulit. Kalau keuntungan performanya kecil, kerumitannya tidak sepadan.
  6. Benchmark dengan data nyata. Microbenchmark menipu. Pola overhead baru kelihatan dengan input ukuran production dan pola pemanggilan yang realistis.

Pelajaran dari Rewrite: Sebelum dan Sesudah

Biar saya tunjukkan angka sebenarnya dari migrasi parser kami. Ini bukan benchmark sintetis, melainkan hasil memproses korpus dokumen nyata kami: 200 file dengan panjang 50 sampai 2.000 baris.

Metric                  | Rust/WASM    | TypeScript   | Delta
------------------------|--------------|--------------|--------
Median parse time       | 23.1ms       | 14.4ms       | -38%
P99 parse time          | 58.3ms       | 30.7ms       | -47%
Bundle size (gzip)      | 380KB        | 26KB         | -93%
Cold start              | 142ms        | 18ms         | -87%
Boundary crossings/doc  | ~12,000      | 0            | -100%
String serialization    | 31% of time  | 0%           | eliminated
Debug/profile ease      | painful      | native       | huge win

Rewrite TypeScript itu bukan port langsung. Saya mendesain ulang representasi AST supaya cocok dengan optimizer V8: bentuk objek monomorphic, enum numerik sebagai pengganti tag string, dan penyimpanan array datar sebagai pengganti tree yang penuh pointer. Ini optimasi yang tidak akan kamu pikirkan di Rust karena compiler Rust sudah menangani detail sedalam itu. Di JavaScript, kamu harus bertemu JIT di tengah jalan.

// AST node design optimized for V8's hidden class system
// Every node has identical shape = monomorphic access = fast
const NODE_HEADING = 1;
const NODE_PARAGRAPH = 2;
const NODE_CODE = 3;
const NODE_LIST = 4;
interface ASTNode {
type: number;       // numeric, not string — cheaper comparisons
start: number;      // byte offset
end: number;
parent: number;     // index into nodes array, not a reference
firstChild: number; // -1 if none
nextSibling: number; // -1 if none
flags: number;      // bitfield for boolean props
}
// Pre-allocate and reuse — minimal GC pressure
const pool: ASTNode[] = new Array(1024);
let poolIdx = 0;
function allocNode(type: number, start: number, end: number, parent: number): number {
const i = poolIdx++;
pool[i] = { type, start, end, parent, firstChild: -1, nextSibling: -1, flags: 0 };
return i;
}

Insight utamanya? Kode tercepat bukan kode yang ditulis dengan bahasa tercepat. Melainkan kode yang bekerja bersama runtime-nya, bukan melawannya. Kode Rust kami adalah Rust yang bagus. Tapi begitu dikompilasi ke WASM dan ditanam di aplikasi JavaScript, ia terus-menerus melawan platform di setiap kesempatan.

Berhenti Berasumsi, Mulai Mengukur

Saya masih memakai WASM. Saya punya pipeline pemrosesan gambar berbasis WASM yang 4x lebih cepat daripada apa pun yang bisa saya tulis di JavaScript, karena ia bekerja di buffer piksel mentah tanpa satu pun lintas batas. Tool yang tepat, untuk pekerjaan yang tepat.

Tapi saya sudah berhenti otomatis memilih WASM setiap kali butuh performa. Default baru saya: tulis versi TypeScript dulu, ukur dengan data nyata, dan baru pakai WASM kalau profiler bilang perlu, dan hanya untuk hot path spesifik yang memang lambat. Bukan seluruh modul. Bukan seluruh fitur. Cuma loop komputasi ketat yang benar-benar diuntungkan oleh kompilasi ahead-of-time.

Tulis dengan TypeScript. Ukur. Kalau sudah cukup cepat, kirim. Kalau belum, pindahkan hot loop, dan hanya hot loop, ke WASM. Hasilnya kodemu lebih sedikit, bundle lebih kecil, dan aplikasinya lebih cepat.

Platform web memberimu dua model eksekusi yang kuat. Pakai keduanya. Tapi pakailah di tempat yang benar-benar membantu, bukan di tempat yang menurut intuisimu seharusnya.