How WebAssembly runs native-speed code in the browser
How WebAssembly compiles high-level languages to a portable binary format, gets validated and compiled by the browser engine, and interoperates with JavaScript through linear memory and import/export tables.
The Interview Question
Interviewer: "Your team is considering using WebAssembly to move a computationally intensive image processing pipeline from a backend service into the browser. Walk me through how WebAssembly actually executes code in the browser. How does it achieve near-native performance, and what are the boundaries between Wasm and JavaScript?"
This question tests whether you understand the compilation pipeline, the execution model, the memory architecture, and the interop layer. The interviewer wants to hear about binary format validation, linear memory, tiered compilation, and the practical constraints of running native code in a browser sandbox.
What to Clarify Before Answering
You: "Before I dive into the execution model, let me scope a few things..."
- "Should I cover the full pipeline from source language (C/Rust) through LLVM to .wasm, or start from the binary format?"
- "Are we using WebAssembly in a browser context, or also considering WASI for server-side execution?"
- "Should I address the JavaScript interop layer (imports, exports, shared memory), or focus on the Wasm execution engine itself?"
- "Do you want me to cover threading with SharedArrayBuffer and atomics?"
- "Should I discuss the component model and interface types, or stick to the core Wasm 1.0/2.0 spec?"
Why this matters: WebAssembly spans multiple domains: compiler toolchains, browser engine internals, memory management, and cross-language interop. Scoping lets you go deep on the execution model (which is what most interviewers care about) instead of giving a shallow overview of the entire ecosystem.
The 30-Second Answer
WebAssembly (Wasm) is a binary instruction format designed as a portable compilation target for high-level languages like C, C++, Rust, and Go. Source code compiles through LLVM (or similar toolchains) into a .wasm binary module. When the browser loads this module, it validates the binary for type safety and memory safety in a single pass, then compiles it to native machine code using a tiered compilation strategy (fast baseline compile first, then optimized recompile in the background). Wasm executes in a sandboxed environment with its own linear memory (a contiguous ArrayBuffer) that JavaScript can read and write through TypedArrays. Functions are shared between Wasm and JavaScript through import/export tables, enabling interoperation. The result is code that runs at 80-95% of native speed while maintaining the browser's security guarantees.
The Architecture Overview
Looking at the diagram above, the pipeline has three phases. The toolchain phase compiles source languages to a portable .wasm binary. The browser engine phase validates and compiles that binary to native machine code. The runtime phase executes the compiled code with its own memory space and interop bridge to JavaScript.
I find this design particularly well-thought-out because it separates concerns cleanly. The source language does not matter to the browser. The browser's compilation strategy does not matter to the developer. The only contract is the .wasm binary format specification.
The Binary Format: What a .wasm File Contains
A .wasm file is a compact binary encoding of a module. It is not bytecode that gets interpreted (like JVM bytecode in early Java). It is a structured binary format designed to be validated and compiled to native code as fast as possible.
Module Sections
Every Wasm module consists of well-defined sections in a fixed order:
| Section | ID | Contents |
|---|---|---|
| Type | 1 | Function signatures (parameter types, return types) |
| Import | 2 | Functions, memories, tables, globals imported from host |
| Function | 3 | Maps function indices to type signatures |
| Table | 4 | Indirect function call tables (for function pointers) |
| Memory | 5 | Linear memory declarations (initial size, max size) |
| Global | 6 | Global variables with types and mutability |
| Export | 7 | Functions, memories, tables exposed to the host |
| Start | 8 | Optional entry point function index |
| Element | 9 | Initialization data for tables |
| Code | 10 | Function bodies (local variables + instructions) |
| Data | 11 | Initialization data for linear memory |
// Wasm binary structure (hex dump of a minimal module)
00 61 73 6d // Magic number: \0asm
01 00 00 00 // Version: 1
// Type section
01 07 // Section ID 1, 7 bytes
01 // 1 type entry
60 02 7f 7f // func(i32, i32) -> i32
01 7f
// Function section
03 02 // Section ID 3, 2 bytes
01 00 // 1 function, uses type index 0
// Export section
07 07 // Section ID 7, 7 bytes
01 // 1 export
03 61 64 64 // Name: "add"
00 00 // Kind: function, index 0
// Code section
0a 09 // Section ID 10, 9 bytes
01 07 // 1 function body, 7 bytes
00 // 0 local variables
20 00 // local.get 0
20 01 // local.get 1
6a // i32.add
0b // end
This binary for a simple add(a, b) function is 41 bytes. The equivalent JavaScript function add(a, b) { return a + b; } is 38 bytes as text, but requires parsing, AST construction, and JIT compilation. The Wasm binary skips all of that because its structure is already optimized for fast machine consumption.
Why this matters in production
The binary format was specifically designed for streaming compilation. The browser can start compiling function bodies before the entire file has downloaded. This is why WebAssembly.instantiateStreaming() exists and why it is always faster than downloading the full file first and then compiling. For a 5MB Wasm module, streaming compilation can reduce startup time by 40-60%.
Type System
Wasm has exactly four value types in the core spec (with extensions adding more):
| Type | Description | Size |
|---|---|---|
i32 | 32-bit integer | 4 bytes |
i64 | 64-bit integer | 8 bytes |
f32 | 32-bit IEEE 754 float | 4 bytes |
f64 | 64-bit IEEE 754 float | 8 bytes |
There are no strings, objects, or garbage-collected references in core Wasm. Strings are represented as byte sequences in linear memory. This minimalism is intentional: it makes the type system trivially verifiable and enables predictable performance.
Browser Compilation: From Binary to Native Code
When the browser receives a .wasm file, it does not interpret the instructions. It compiles them to native machine code. This is the key to Wasm's performance: by the time your code runs, it is the same kind of native instructions that a C program compiled with gcc would produce.
Tiered Compilation Strategy
Every major browser engine uses tiered compilation for Wasm. This is the same strategy used for JavaScript JIT compilation, but Wasm benefits more because its binary format is already optimized for compilation.
Tier 1 (Baseline): A fast, single-pass compiler that generates native code quickly but without heavy optimization. V8 calls this "Liftoff." It compiles each Wasm function in microseconds, so the module is executable almost immediately after download. The generated code runs about 10-20% slower than fully optimized code.
Tier 2 (Optimizing): A full optimizing compiler that runs in a background thread. V8 calls this "TurboFan." It performs register allocation, instruction selection, and function inlining. When it finishes compiling a function, the baseline code is atomically replaced with the optimized version. The user never notices the switch.
// Tiered compilation timeline for a 2MB .wasm module
//
// Time 0ms: Download starts, streaming compilation begins
// Time 50ms: First functions compiled by Liftoff (baseline)
// Time 200ms: Download complete, all baseline code ready
// Time 200ms: Execution starts (baseline code)
// Time 500ms: TurboFan starts optimizing hot functions
// Time 2000ms: Most hot functions replaced with optimized code
Why Wasm Compiles Faster Than JavaScript
JavaScript requires parsing text into an AST, resolving scopes, handling dynamic types, and making speculative optimizations that can be invalidated at any time. Wasm skips all of this:
| Step | JavaScript | WebAssembly |
|---|---|---|
| Parsing | Text parsing, AST construction | Binary decoding, no AST needed |
| Type analysis | Dynamic, inferred at runtime | Static, declared in binary |
| Optimization speculation | Types can change, must deoptimize | Types are fixed, no deoptimization |
| Compilation speed | ~1MB/s (optimizing tier) | ~10-30MB/s (baseline tier) |
| Predictability | JIT warmup, deopt cliffs | Consistent performance from start |
The key insight
Wasm is not faster than JavaScript because of some magic instruction set. It is faster because its binary format gives the compiler perfect information upfront. The compiler never has to guess types, never has to speculate about object shapes, and never has to bail out and recompile when an assumption is violated. This determinism is what makes Wasm performance predictable, which is arguably more important than raw speed for many applications.
Linear Memory: How Wasm Manages Data
This is the part of WebAssembly that surprises most web developers. Wasm does not use the JavaScript heap. It does not have a garbage collector. It manages its own memory as a flat, contiguous block of bytes.
The Memory Model
Wasm's linear memory is a contiguous, byte-addressable array that starts at index 0 and grows upward. In the browser, it is backed by a JavaScript ArrayBuffer (or SharedArrayBuffer for threads).
// Linear memory layout (simplified)
// Address 0x0000 - 0x0FFF: Static data (strings, constants)
// Address 0x1000 - 0x7FFF: Heap (malloc/free managed)
// Address 0x8000 - 0xFFFF: Stack (grows downward from top)
//
// Total: 64KB initial (1 page = 64KB)
// Maximum: configurable, up to 4GB (65,536 pages)
Key properties of linear memory:
-
Bounds-checked: Every memory access is checked against the current memory size. Out-of-bounds access traps (throws an error) instead of corrupting memory. This is how Wasm maintains safety despite allowing manual memory management.
-
Growable: The
memory.growinstruction adds pages (64KB each) to the memory. This is similar toreallocin C. The previous contents are preserved; new pages are zero-initialized. -
Not garbage-collected: Wasm does not have a GC. Languages that compile to Wasm either bring their own allocator (C/C++ use dlmalloc or similar, Rust uses its standard allocator) or rely on the GC proposal (still evolving) for managed languages.
Passing Data Between JavaScript and Wasm
This is one of the trickiest parts of working with WebAssembly. Wasm functions can only accept and return numbers (i32, i64, f32, f64). You cannot pass a JavaScript string or object directly to a Wasm function.
To pass a string from JavaScript to Wasm:
// JavaScript side
const encoder = new TextEncoder();
const bytes = encoder.encode("Hello, Wasm!");
// Write bytes into Wasm's linear memory
const memory = new Uint8Array(wasmInstance.exports.memory.buffer);
const ptr = wasmInstance.exports.alloc(bytes.length); // Wasm allocator
memory.set(bytes, ptr);
// Call Wasm function with pointer and length
const result = wasmInstance.exports.processString(ptr, bytes.length);
// Read result back from linear memory
const resultPtr = wasmInstance.exports.getResultPtr();
const resultLen = wasmInstance.exports.getResultLen();
const resultBytes = memory.slice(resultPtr, resultPtr + resultLen);
const resultStr = new TextDecoder().decode(resultBytes);
This pointer-and-length passing pattern is the fundamental interop mechanism. Every complex data type (strings, arrays, structs) gets serialized into linear memory and passed as a pointer.
What most people get wrong
The linear memory ArrayBuffer can be detached when memory.grow is called. If JavaScript holds a reference to a TypedArray view of Wasm memory and Wasm grows its memory, the old view becomes invalid. You must re-create the TypedArray view after any operation that might grow memory. This is a common source of bugs in Wasm-JS interop code.
JavaScript Interop: Import/Export Tables
The import/export system is how Wasm modules connect to the outside world. A Wasm module declares what it needs (imports) and what it provides (exports). The host environment (browser or Node.js) satisfies those imports at instantiation time.
Exports
A Wasm module can export functions, memory, tables, and globals. The most common pattern is exporting functions that JavaScript calls:
// Instantiate and call exported Wasm functions
const { instance } = await WebAssembly.instantiateStreaming(
fetch('image_processor.wasm'),
importObject
);
// Call exported functions directly
const result = instance.exports.blur(imagePtr, width, height, radius);
const compressed = instance.exports.compress(dataPtr, dataLen);
// Access exported memory
const memory = instance.exports.memory;
const view = new Uint8Array(memory.buffer);
Imports
Imports let Wasm call JavaScript functions. This is how Wasm modules access browser APIs (DOM, fetch, WebGL) that they cannot access directly:
const importObject = {
env: {
// Wasm calls this to log a message
console_log: (ptr, len) => {
const bytes = new Uint8Array(memory.buffer, ptr, len);
console.log(new TextDecoder().decode(bytes));
},
// Wasm calls this to get current time
performance_now: () => performance.now(),
// Wasm calls this to make an HTTP request
fetch_url: (urlPtr, urlLen, callbackId) => {
const url = decodeString(urlPtr, urlLen);
fetch(url).then(r => r.arrayBuffer()).then(buf => {
writeToWasmMemory(buf, callbackId);
});
}
},
wasi_snapshot_preview1: {
// WASI system calls for server-side Wasm
fd_write: (fd, iovs, iovsLen, nwritten) => { /* ... */ },
fd_read: (fd, iovs, iovsLen, nread) => { /* ... */ },
}
};
The Interop Cost
Crossing the JavaScript-Wasm boundary has a cost. Each call between JS and Wasm requires type conversion, stack frame setup, and security checks. For a single function call, this overhead is roughly 50-100 nanoseconds in V8, which is negligible for most use cases.
The problem arises when crossing the boundary thousands of times per frame. I have seen teams move an image processing loop to Wasm but call back into JavaScript for every pixel. The interop overhead dominated the actual computation. The correct approach is to batch work: pass a pointer to the entire image buffer, process it entirely in Wasm, and return the result pointer.
WASI: WebAssembly Beyond the Browser
WASI (WebAssembly System Interface) extends Wasm to run outside browsers by providing a standardized interface for file I/O, networking, clocks, and random number generation. It is essentially a POSIX-like system call layer for Wasm.
Why WASI Matters
In the browser, Wasm gets capabilities through JavaScript imports. On a server, there is no JavaScript host. WASI provides a portable, capability-based API that any Wasm runtime (Wasmtime, Wasmer, WasmEdge) can implement.
// WASI enables this Rust code to compile to Wasm and run on any WASI runtime
use std::fs;
use std::io::Write;
fn main() {
let data = fs::read("input.txt").unwrap();
let processed = process(data);
let mut output = fs::File::create("output.txt").unwrap();
output.write_all(&processed).unwrap();
}
// Compiles with: cargo build --target wasm32-wasi
// Runs with: wasmtime output.wasm
The key insight of WASI is capability-based security. A WASI module cannot access any file or network resource unless the host explicitly grants it. You run wasmtime --dir=/data output.wasm to give the module access to /data and nothing else. This is a stronger security model than containers, which share the host kernel.
Why this matters in production
WASI is being adopted for serverless edge computing. Cloudflare Workers, Fastly Compute, and Fermyon Spin all run customer code as Wasm modules with WASI. The cold start time for a Wasm module is under 1ms (compared to 50-500ms for a container), and the memory footprint is 10-100x smaller. This makes Wasm ideal for edge functions that need to start fast and handle many concurrent requests.
Threads and SharedArrayBuffer
Wasm supports multi-threading through the threads proposal, which uses SharedArrayBuffer for shared memory and atomic operations for synchronization.
How It Works
- The main thread creates a
SharedArrayBufferand passes it as the Wasm module's memory - Web Workers are spawned, each instantiating the same Wasm module with the shared memory
- Wasm code uses
memory.atomic.waitandmemory.atomic.notifyfor synchronization - Atomic load/store operations provide safe concurrent memory access
// Creating a shared-memory Wasm instance
const memory = new WebAssembly.Memory({
initial: 256, // 256 pages = 16MB
maximum: 1024, // 1024 pages = 64MB
shared: true // Uses SharedArrayBuffer
});
// Main thread
const { instance } = await WebAssembly.instantiateStreaming(
fetch('parallel_processor.wasm'),
{ env: { memory } }
);
// Spawn worker threads
for (let i = 0; i < navigator.hardwareConcurrency; i++) {
const worker = new Worker('wasm-worker.js');
worker.postMessage({ memory, wasmUrl: 'parallel_processor.wasm', threadId: i });
}
This enables true parallel computation in the browser. Figma uses Wasm threads for multi-threaded rendering. Game engines use them for physics simulation and AI pathfinding running on separate threads.
Real-World Use Cases
Understanding where Wasm shines (and where it does not) is important for making informed architecture decisions.
| Application | Company | Why Wasm |
|---|---|---|
| Design tool (vector editor) | Figma | C++ renderer compiled to Wasm, 3-10x faster than Canvas JS |
| 3D globe rendering | Google Earth | C++ engine ported from native, runs at 60fps in browser |
| SQLite in browser | sql.js | Full SQL database engine, no server needed |
| Video editing | Clipchamp | ffmpeg compiled to Wasm, client-side video processing |
| Image compression | Squoosh | Multiple codecs (MozJPEG, WebP, AVIF) compiled to Wasm |
| PDF rendering | pdfium (Chrome) | C++ PDF engine, faster than JS alternatives |
| Game engines | Unity, Unreal | Full game engines running in browser via Emscripten |
| Blockchain | Polkadot, Near | Wasm as smart contract execution environment |
When NOT to Use Wasm
I want to be clear about where Wasm is not the right choice:
- DOM manipulation: Wasm cannot access the DOM directly. Every DOM call goes through JavaScript imports. If your workload is DOM-heavy, JavaScript is faster because it avoids the interop overhead.
- Simple CRUD APIs: If your code is mostly fetching data and rendering templates, JavaScript is simpler and fast enough. Wasm adds compilation complexity for no performance gain.
- String-heavy processing: Wasm has no native string type. String processing requires encoding/decoding through linear memory, which adds overhead that can negate the computational speedup.
- Small code with frequent JS interop: If the Wasm module is tiny but calls JavaScript hundreds of times per frame, the interop cost dominates.
What Happens When Things Break
| Failure | What Happens | How to Detect | How to Fix |
|---|---|---|---|
| Out-of-bounds memory access | Wasm traps (throws RuntimeError) | Error in browser console | Check pointer arithmetic, validate buffer sizes |
| Memory growth failure | memory.grow returns -1 | Check return value of memory.grow | Set higher maximum memory, or handle allocation failure |
| Stack overflow | Wasm traps (call stack exhaustion) | RuntimeError: call stack exhausted | Reduce recursion depth, use iterative algorithms |
| Integer overflow | Wraps silently (modular arithmetic) | Incorrect results, no error | Use explicit overflow checks in source code |
| Detached ArrayBuffer | TypedArray operations throw TypeError | TypeError in JS code after Wasm calls | Re-create TypedArray views after memory.grow |
| Module too large | Long compilation time, memory pressure | Slow startup, browser tab crash | Split into smaller modules, use lazy loading |
Performance Characteristics
| Operation | Wasm | JavaScript | Native (C/C++) |
|---|---|---|---|
| Integer arithmetic | ~1.0-1.1x native | ~1.5-3x native | 1.0x (baseline) |
| Floating-point math | ~1.0-1.1x native | ~1.2-2x native | 1.0x (baseline) |
| Memory access (linear) | ~1.0-1.2x native | ~1.5-3x native | 1.0x (baseline) |
| Function call overhead | ~5ns (within Wasm) | ~10-50ns | ~2-3ns |
| JS-Wasm boundary | ~50-100ns per call | N/A | N/A |
| Module startup (2MB) | ~200ms (streaming) | N/A | N/A |
| SIMD operations | ~1.0-1.1x native | Not available | 1.0x (baseline) |
| String processing | ~0.5-0.8x JS speed | 1.0x (baseline) | N/A |
How This Compares to Alternatives
| Feature | WebAssembly | JavaScript (JIT) | asm.js | Java Applets | Flash/ActionScript |
|---|---|---|---|---|---|
| Performance | 80-95% of native | 40-80% of native | 50-70% of native | 60-80% of native | 30-50% of native |
| Startup time | < 200ms (streaming) | Instant (interpreted) | Instant (text) | 2-5 seconds (JVM) | 1-3 seconds |
| Memory model | Linear (manual) | GC-managed heap | GC-managed heap | GC-managed heap | GC-managed heap |
| Security | Sandboxed, validated | Sandboxed | Sandboxed | Plugin (security holes) | Plugin (deprecated) |
| Browser support | All modern browsers | Universal | All browsers | Removed | Removed |
| Source languages | C, C++, Rust, Go, etc. | JavaScript only | C/C++ (subset) | Java only | ActionScript only |
| Threading | SharedArrayBuffer | Web Workers (no shared mem) | No | Yes (in JVM) | No |
I reach for Wasm when the workload is computationally intensive (image/video processing, compression, cryptography, physics simulation, data transformation). I stick with JavaScript for DOM-heavy applications, simple data fetching, and any case where developer productivity matters more than raw performance. The sweet spot is hybrid: JavaScript for UI and coordination, Wasm for the hot compute path.
Interview Cheat Sheet
- When asked what Wasm is: "WebAssembly is a binary instruction format designed as a portable compilation target. Source languages like C, Rust, and Go compile to .wasm binaries that browsers validate and compile to native machine code, achieving 80-95% of native performance."
- When asked how it achieves near-native speed: "The binary format provides static types and structured control flow, so the browser's compiler has perfect information upfront. It uses tiered compilation: a fast baseline compiler for instant startup, and an optimizing compiler running in the background for peak performance."
- When asked about the memory model: "Wasm uses linear memory, a contiguous ArrayBuffer that the module manages manually (no garbage collector). JavaScript can read and write this same buffer through TypedArrays. Data passes between JS and Wasm as pointers into this shared buffer."
- When asked about JS interop: "Wasm modules declare imports and exports. Functions crossing the JS-Wasm boundary cost about 50-100ns per call. The key is to batch work: pass a pointer to a large buffer, process it entirely in Wasm, and return the result pointer."
- When asked about security: "Wasm runs in the same sandbox as JavaScript. All memory accesses are bounds-checked. The binary is validated in a single pass before compilation. Out-of-bounds access traps instead of corrupting memory. WASI adds capability-based security for server-side use."
- When asked when NOT to use Wasm: "DOM manipulation (every DOM call goes through JS imports), string-heavy processing (no native string type), simple CRUD apps (complexity overhead with no performance gain), and any workload with frequent JS-Wasm boundary crossings."
- When asked about WASI: "WASI provides a portable system interface (file I/O, networking) for running Wasm outside the browser. It uses capability-based security where the host explicitly grants access to resources. It is being adopted for serverless edge computing with sub-millisecond cold starts."
- When asked about real-world examples: "Figma runs a C++ rendering engine as Wasm for 3-10x performance over Canvas JS. Google Earth renders a 3D globe at 60fps. Squoosh uses Wasm codecs for client-side image compression. SQLite runs entirely in the browser via sql.js."
Test Your Understanding
Quick Recap
- WebAssembly is a portable binary instruction format that compiles to native machine code in the browser, achieving 80-95% of native speed with full sandbox security.
- Source languages (C, C++, Rust, Go) compile through LLVM to
.wasmbinaries containing typed functions, linear memory declarations, and import/export tables. - Browsers validate Wasm binaries in a single O(n) pass, checking type safety, memory bounds, and structured control flow before any code executes.
- Tiered compilation (baseline compiler for instant startup, optimizing compiler in background) gives both fast startup and peak steady-state performance.
- Linear memory is a contiguous ArrayBuffer managed without garbage collection. JavaScript accesses the same buffer through TypedArrays, passing complex data as pointers.
- The JS-Wasm boundary costs about 50-100ns per call. Batch work into single calls with buffer pointers instead of making per-element calls.
- WASI extends Wasm to server-side execution with capability-based security, enabling sub-millisecond cold starts for serverless edge computing.
- Wasm excels at compute-intensive workloads (image processing, compression, physics, crypto) but is not suitable for DOM-heavy applications or string processing.
Related Concepts
- JIT compilation: Wasm's tiered compilation uses the same baseline-then-optimize strategy as JavaScript JIT compilers, but benefits from static typing.
- Browser rendering pipeline: Understanding how Wasm interacts with the rendering pipeline (requestAnimationFrame, OffscreenCanvas, WebGL) is essential for graphics-heavy Wasm applications.
- Container isolation: WASI's capability-based security model provides stronger isolation than containers with lower overhead. Understanding both models helps in evaluating serverless platforms.
- LLVM and compiler toolchains: Wasm's compilation pipeline depends on LLVM for most source languages. Understanding IR optimization passes explains why certain code patterns compile to faster Wasm.
- SharedArrayBuffer and Web Workers: Wasm threading builds on the same primitives as JavaScript parallelism, but with lower-level atomic operations for fine-grained synchronization.