WebAssembly हमेशा JavaScript से तेज़ क्यों नहीं होता
WebAssembly हमेशा JavaScript से तेज़ नहीं होता। असली benchmarks दिखाते हैं कि TypeScript कब WASM को हराता है और boundary crossing प्रदर्शन क्यों बिगाड़ता है।

पिछले महीने मैंने एक शनिवार Rust-to-WASM वाले इमेज मेटाडेटा पार्सर को TypeScript में दोबारा लिखने में बिताया। वह WASM वर्शन आठ महीने से production में था। ठीक चल रहा था, किसी ने शिकायत भी नहीं की थी। लेकिन हमारे performance traces को देखते हुए कुछ खटक रहा था: WASM पार्सर का ज़्यादातर समय असल parsing के बजाय JS-WASM boundary के पार data ले जाने में जा रहा था। तो मैंने उसे दोबारा लिखा। सिर्फ़ TypeScript, कोई WASM नहीं। नतीजा: हमारे median payload पर यह 38% तेज़ निकला। मैंने कोई बेहतर algorithm नहीं लिखा था। बस border crossing वाला टैक्स देना बंद कर दिया।
उस अनुभव ने मेरे दिमाग में कुछ हिला दिया। मैं उन developers में से था जो मानते थे कि WebAssembly ही तेज़ विकल्प है, बस खत्म बात। पता चला कि यह धारणा हम में से ज़्यादातर लोगों के अंदाज़े से कहीं ज़्यादा बार गलत साबित होती है।
"WASM हमेशा तेज़ होता है" वाले मिथक को अब खत्म होना चाहिए
ज़्यादातर developers के दिमाग में यह मॉडल होता है: compiled language को WASM में बदलो, WASM लगभग native speed पर चलता है, इसलिए WASM, JavaScript से तेज़ है। इस श्रृंखला का हर कदम काफी हद तक सही है। पर निष्कर्ष नहीं बनता, क्योंकि यह computation के आसपास की लागत को नज़रअंदाज़ कर देता है: data अंदर लाना, नतीजे बाहर निकालना, और दो runtimes को साथ-साथ चलाने का overhead।
JavaScript engines कमाल के हैं। V8, SpiderMonkey और JavaScriptCore में दशकों का optimization काम है, जिसे दुनिया की कुछ सबसे अमीर कंपनियों ने फंड किया है। ये speculative JIT compilation, inline caching, escape analysis और hidden class transitions करते हैं। कई workloads के लिए, खासकर web apps में आम string-heavy, object-heavy और callback-heavy पैटर्न के लिए, एक आधुनिक JS engine ऐसा machine code बनाता है जो static compiler से मिलने वाले कोड के काफी करीब होता है।
WASM को ये optimizations नहीं मिलते। यह AOT-compiled होता है। आप जो ship करते हैं, वही चलता है। यह predictability के लिए अच्छा फीचर है, लेकिन इसका मतलब है कि WASM runtime patterns के हिसाब से खुद को उस तरह adapt नहीं कर सकता जैसे JIT कर सकता है।
Boundary Crossing की समस्या: हज़ार कॉल्स से होने वाली मौत
यही सबसे बड़ी समस्या है। JavaScript से WASM में (या वापस) हर call में overhead होता है। Engine को data marshal करना पड़ता है, types validate करने पड़ते हैं, execution context बदलना पड़ता है। एक crossing सस्ती है, कुछ माइक्रोसेकंड। लेकिन जो workloads एक operation में हज़ारों बार boundary पार करते हैं, वे इस overhead से बुरी तरह मारे जाते हैं।
मैं यह पैटर्न लगातार देखता हूँ: कोई टीम Rust में parser, transformer या validator लिखती है, उसे WASM में compile करती है, और फिर JavaScript glue code में लपेटती है जो हर node, हर token, हर step पर WASM को call करता है। WASM core अलग से देखने में बहुत तेज़ हो सकता है, पर glue code उसे टोल रोड बना देता है।
// 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.
मैंने इसी स्थिति का एक असली codebase पर benchmark किया। WASM वर्शन ने 100 config files का batch 340ms में प्रोसेस किया। TypeScript पोर्ट ने उसी काम को 195ms में किया। ऐसा इसलिए नहीं कि TypeScript कोई तेज़ भाषा है, वह नहीं है, बल्कि इसलिए कि काम कभी एक execution context से बाहर गया ही नहीं।
Strings WASM का प्रदर्शन क्यों बिगाड़ते हैं
WASM linear memory पर काम करता है, यानी bytes का एक सपाट buffer। JavaScript strings बिल्कुल अलग चीज़ हैं। ये engine-managed objects हैं जो खास internal formats (Latin1, UTF-16, ropes, cons strings) में रहते हैं। JS से string को WASM में ले जाने के लिए उसे UTF-8 में encode करना पड़ता है, linear memory में जगह देनी पड़ती है, और bytes copy करने पड़ते हैं। वापस लाने पर फिर copy और decode।
Number-crunching कोड के लिए यह मायने नहीं रखता। आप TypedArray अंदर डालते हैं, numbers वापस पाते हैं। लेकिन जो काम string पर भारी निर्भर है, जैसे parsers, template engines, validators और serializers, वहाँ आप हर string fragment पर encode-copy-process-copy-decode का टैक्स चुकाते हैं।
// 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.
मैंने अपने WASM पार्सर को profile किया और पाया कि कुल execution time का 31% string serialization में जा रहा था। Parsing नहीं। Tree building नहीं। बस strings को boundary के आर-पार आगे-पीछे भेजना। TypeScript वर्शन ने काम की यह पूरी श्रेणी ही खत्म कर दी।
अगर आपका hot path string-heavy है, तो WASM शायद उसे तेज़ करने के बजाय धीमा कर रहा है। Serialization का overhead असली है और बहुत जल्दी बढ़ता है।
JIT Compilation: वह performance बढ़त जिसके बारे में कोई बात नहीं करता
WASM का AOT मॉडल predictable, consistent performance देता है। कुछ use cases के लिए यह बहुत अच्छा है। पर JavaScript का JIT मॉडल V8 को आपके कोड को चलते हुए देखने देता है, और फिर वह उस असली data के हिसाब से optimized machine code बनाता है जो उसमें बह रहा है। समय के साथ JIT-compiled कोड अविश्वसनीय रूप से तेज़ हो सकता है।
Object shape specialization को लीजिए। अगर आप ऐसे objects के array को प्रोसेस करते हैं जिनमें एक ही क्रम में एक ही properties हैं, तो V8 इस monomorphic पैटर्न को पहचानकर ऐसा machine code बनाता है जो properties को fixed memory offset से एक्सेस करता है: कोई hash table lookup नहीं, कोई type checking नहीं। यह basically dynamic JavaScript में C-struct जैसा प्रदर्शन है।
// 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 यह नहीं कर सकता। उसकी performance विशेषताएँ compile time पर तय हो जाती हैं। सख्त, predictable numerical loops के लिए यह ठीक है, क्योंकि Rust compiler ने पहले ही उसे खूब optimize कर दिया होता है। लेकिन उलझे हुए, polymorphic, callback-heavy कोड के लिए, जो ज़्यादातर web applications की पहचान है, runtime पर specialize करने की JIT की क्षमता एक असली फायदा है।
Bundle Size और Cold Start: वे metrics जिन्हें आप भूल जाते हैं
Throughput ही एकमात्र performance metric नहीं है। यूज़र load time, interaction latency और पहले result तक पहुँचने का समय महसूस करते हैं। WASM की तीनों में असली लागत है।
- wasm-bindgen वाला एक Rust WASM module आमतौर पर gzip के बाद 100KB-1.5MB का होता है। वही काम TypeScript में, minified और gzipped, आमतौर पर 10-40KB होता है।
- WASM modules को execute होने से पहले native code में compile होना पड़ता है। Streaming compilation मदद करती है, पर यह browser का अभी भी करने वाला काम है।
- JavaScript engines lazily parse करते हैं, यानी functions तब तक compile नहीं होते जब तक उन्हें call न किया जाए। WASM शुरू में ही पूरी compilation लागत चुकाता है।
- V8 का code cache मतलब है कि बार-बार आने वाले visits पर JS parsing पूरी तरह छूट जाती है। WASM compilation caching भी है, पर वह कम परिपक्व है।
हमारे प्रोजेक्ट से एक असली तुलना: WASM पार्सर bundle gzip के बाद 380KB का था। TypeScript replacement 26KB का निकला। अच्छे connection पर तो ठीक है। लेकिन throttled 3G connection पर (जिसे मैं हर performance review के लिए simulate करता हूँ), WASM वर्शन ने 1.8 सेकंड अतिरिक्त load time जोड़ दिया। यह कोई मामूली अंतर नहीं है, यही फर्क है कि यूज़र रुकता है या बाउंस कर जाता है।
Cold start भी मायने रखता है। मिड-रेंज फोन पर WASM module को compile और instantiate होने में लगभग 110ms लगे। TypeScript वर्शन 20ms से कम में interactive हो गया। ऐसा tool जिसे यूज़र दिन में दर्जनों बार खोलते हैं, उसके लिए यह जमा होकर असली झुंझलाहट बन जाता है।
जब WASM सच में जीतता है (और तब आपको इसे इस्तेमाल करना चाहिए)
मेरा मतलब यह नहीं कि WASM कोई बुरी तकनीक है। यह एक शानदार तकनीक है जिसका एक खास sweet spot है। दिक्कत यह है कि लोग इसे उस sweet spot के बाहर इस्तेमाल करते हैं और फिर हैरान होते हैं कि चीज़ें धीमी क्यों हो गईं।
WASM तब जीतता है जब computation भारी हो, boundary crossings कम हों, और data numeric हो। जैसे: pixel buffers पर image filters, cryptographic hashing, physics simulations, audio DSP, video codecs। आप data का बड़ा हिस्सा linear memory में डालते हैं, WASM उसे tight loop में crunch करता है, और नतीजा बाहर निकालते हैं। अंदर जाने की एक crossing, बाहर आने की एक crossing, और बीच में ढेर सारी computation। यहीं WASM चमकता है।
WASM तब भी जीतता है जब आपको deterministic performance चाहिए। JIT compilation शक्तिशाली है पर अनप्रेडिक्टेबल है। किसी JS function की शुरुआती कुछ calls interpreted चलती हैं, फिर baseline-compiled होती हैं, और शायद फिर optimizing-compiled। अगर आपको पहली invocation से ही consistent latency चाहिए (game loops, real-time audio, financial calculations), तो WASM का ahead-of-time compilation वही देता है।
और जब आप मौजूदा native codebase को port कर रहे हों, तब WASM स्पष्ट रूप से सही फैसला है। WASM का overhead टालने के लिए कोई SQLite को JavaScript में दोबारा नहीं लिखेगा। यह पागलपन होगा। मौजूदा C कोड में दशकों का optimization है, जिसे आप कभी दोहरा नहीं पाएँगे।
एक व्यावहारिक निर्णय ढाँचा: WASM या TypeScript?
तीन प्रोजेक्ट्स में यह अभ्यास करने के बाद, मैं एक सरल checklist पर पहुँचा हूँ। यह परफेक्ट नहीं है, पर इसने मुझे दो बार गलत फैसले से बचाया है।
- Boundary crossings गिनिए। अगर आपका WASM module एक operation में कुछ सौ बार से ज़्यादा JS को call करता है (या उल्टा), तो TypeScript शायद जीतेगा। पक्का करने के लिए profile करें।
- अपने data types देखिए। ज़्यादातर numbers और typed arrays? WASM सही फिट है। ज़्यादातर strings, objects और callbacks? JS में ही रहें।
- Startup लागत नापिए। अगर कोड page load पर या user clicks के जवाब में चलता है, तो WASM की compilation लागत मायने रखती है। अगर वह लंबे समय तक चलने वाले worker में चलता है, तो startup लागत बँट जाती है।
- अपना bundle budget जाँचिए। अगर bundle size पहले से ही परेशानी है, तो 400KB का WASM module जोड़ना नुकसान करेगा।
- DX लागत पर विचार कीजिए। WASM Rust या C++ build system, source map की जटिलता और कठिन debugging जोड़ता है। अगर performance लाभ मामूली है, तो यह जटिलता उसके लायक नहीं।
- असली data से benchmark कीजिए। Microbenchmarks झूठ बोलते हैं। Overhead के पैटर्न केवल production-size inputs और असली call patterns के साथ दिखते हैं।
Rewrite से मैंने क्या सीखा: पहले और बाद में
हमारे parser migration के असली आँकड़े यहाँ हैं। ये synthetic benchmarks नहीं हैं, बल्कि 50 से 2,000 लाइनों तक की 200 फाइलों के हमारे असली document corpus को प्रोसेस करने से आए हैं।
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
TypeScript rewrite सीधा port नहीं था। मैंने AST representation को V8 के optimizer के साथ अच्छे से चलने के लिए दोबारा डिज़ाइन किया: monomorphic object shapes, string tags की जगह numeric enums, pointer-heavy trees की जगह flat array storage। ये ऐसे optimizations हैं जिनके बारे में Rust में आप कभी सोचते ही नहीं, क्योंकि Rust compiler इस स्तर की बारीकी खुद संभाल लेता है। JavaScript में आपको JIT से बीच में मिलना पड़ता है।
// 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;
}
मुख्य बात क्या है? सबसे तेज़ कोड वह नहीं जो सबसे तेज़ भाषा में लिखा गया हो। सबसे तेज़ कोड वह है जो अपने runtime के साथ काम करता है, उसके खिलाफ नहीं। हमारा Rust कोड बढ़िया Rust था। लेकिन जब उसे WASM में compile करके JavaScript application में embed किया गया, तो वह हर मोड़ पर प्लेटफ़ॉर्म से लड़ रहा था।
अंदाज़ा मत लगाइए, नापिए
मैं अब भी WASM इस्तेमाल करता हूँ। मेरे पास WASM-based image processing pipeline है जो उस किसी भी चीज़ से 4 गुना तेज़ है जो मैं JavaScript में लिख सकता था, क्योंकि वह raw pixel buffers पर zero boundary crossings के साथ काम करती है। सही औज़ार, सही काम।
पर अब मैं performance के लिए WASM को डिफ़ॉल्ट नहीं बनाता। मेरा नया डिफ़ॉल्ट है: पहले TypeScript वर्शन लिखो, असली data पर नापो, और WASM तभी उठाओ जब profiler बताए कि ज़रूरत है, और सिर्फ़ उसी खास hot path के लिए जो सच में धीमा है। पूरा module नहीं। पूरा feature नहीं। सिर्फ़ वह tight computational loop जो सच में ahead-of-time compilation से फायदा उठाती है।
TypeScript में लिखो। नापो। अगर काफी तेज़ है, तो ship करो। अगर नहीं, तो hot loop को, और केवल hot loop को, WASM में ले जाओ। आपके पास कम कोड, छोटे bundles और तेज़ applications होंगे।
Web platform आपको दो शक्तिशाली execution models देता है। दोनों का इस्तेमाल करें। पर उन्हीं जगहों पर जहाँ वे सच में मदद करते हैं, न कि वहाँ जहाँ आपका अंदाज़ा कहता है कि उन्हें होना चाहिए।


