Web Engineering

The Ultimate Guide to WebAssembly (WASM) in Modern Web Apps

Learn how compiled C/C++ and Rust binaries enable near-native performance inside web browsers.

What WebAssembly is, precisely

WebAssembly is a binary instruction format and a compilation target. You rarely write it by hand. A compiler takes C, C++, Rust, Zig or Go and emits a module, and the browser validates that module and compiles it to machine code. The format is deliberately small and deliberately boring: a stack machine, structured control flow instead of arbitrary jumps, and a type system strict enough that validation is a single linear pass over the bytes. Those constraints are the reason a browser can begin compiling a module while it is still downloading.

What it is not is a replacement for JavaScript. A module has no DOM access, no network access and no system calls. It can do arithmetic, read and write its own memory, and call functions the host explicitly imports into it. Everything else remains JavaScript's job. In practice you ship both: a module that does the computation, and a thin JavaScript layer that feeds it bytes and carries results back out.

Why codec work belongs in WebAssembly

The usual pitch is speed, and that framing hides the better argument. Modern JavaScript engines are fast, sometimes startlingly so, but they get there by speculating. The JIT watches which types flow through a function, compiles a specialised version, and guards it. Feed it a value it did not expect and the guard fails, the code deoptimises, and you fall back to slower execution until it recovers. For application code that is invisible. In the inner loop of a decoder it is a stall in the middle of a frame.

A WebAssembly module carries its types in the binary. The compiler knows the shape of every value before anything runs, so there is nothing to guess and nothing to invalidate later. Linear memory is not garbage collected either, so the hot path has no collection pauses to schedule around. Peak throughput between the two is often closer than people expect. Consistency is where the gap opens, and consistency is what a user actually feels when a progress bar moves evenly instead of lurching. The fixed-width 128-bit SIMD instructions help here too, because pixel loops and audio sample loops are exactly the shape that vectorises well.

Then there is the reason that has nothing to do with performance. Image and video codecs, PDF renderers, OCR engines and speech models already exist as mature C and C++ libraries that have absorbed years of bug reports and malformed-input edge cases. Rewriting libjpeg or Tesseract in JavaScript is not a weekend project, and the result would be worse than the original for a long time. WebAssembly lets you compile the existing thing and ship it. That is why most WebAssembly in production is there, and it is a sturdier reason than any benchmark.

Linear memory, and why a tab has a ceiling

A module's memory is one contiguous buffer. Pointers inside the module are offsets into it, it grows in 64 KiB pages, and under wasm32 those pointers are 32 bits wide, which caps addressable memory at 4 GiB. Browsers stop well short of that, and phones stop far shorter still. Three consequences follow, and they are the ones that bite in production.

Growth is effectively one-way. memory.grow extends the buffer, but when the module's allocator frees a block, that block goes back to the module's own heap rather than to the browser. One large job therefore raises the tab's high-water mark for as long as that instance lives. If you process big files back to back, tearing the instance down and creating a fresh one between jobs is more reliable than trusting free to hand anything back.

Growth also needs a contiguous reservation. A grow can fail while the machine still has plenty of free RAM, purely because the address space is fragmented. This shows up most on 32-bit builds and on memory-constrained phones, and it arrives as an exception in the middle of an operation rather than as a polite refusal up front.

Finally, data has to be copied across the boundary. JavaScript cannot hand a File object to a module. You read the file into an ArrayBuffer, copy those bytes into linear memory, run the operation, then copy the result back out to build a Blob. For a moment you are holding the input twice, plus whatever working set the codec itself needs. Budget several times the file size rather than one, set an explicit size limit in the interface, and stream where the format allows it. A refusal you wrote is better than a crash you did not.

The binary you have to ship

A WebAssembly build of a serious codec is measured in megabytes, and it does not compress the way JavaScript does. Minified JavaScript is repetitive text and gzip loves it. A compiled module is already compact binary, so the ratio is worse, although Brotli helps. Machine learning models are harder again: the runtime is a rounding error next to the weights. The rule that follows is simple, and people still break it. Do not load the module during page load. Load it when the user commits to the operation.

A few mechanics make that deferred load hurt less. Serve the file as application/wasm so WebAssembly.instantiateStreaming can compile as bytes arrive instead of buffering the whole download first, because a wrong MIME type silently costs you that. Give the file a content-hashed URL and an immutable cache header so a repeat visit pays nothing. Browsers also cache compiled code for larger modules, which means a returning visitor can skip compilation as well as download.

This site is built on that assumption. Nothing WebAssembly-shaped is in the initial page load. The OCR tool injects its engine only when you actually run OCR, and the transcription worker fetches its speech model the first time you transcribe, then leans on the browser cache afterwards. Someone who converts a JPG to PNG and leaves downloads none of it, because that path never touches WebAssembly at all. It is a canvas draw and an encode call, both handled by the browser's own native code.

Where WebAssembly is the wrong answer

It has no DOM access, so every DOM operation crosses back into JavaScript through glue code. A user interface framework compiled to WebAssembly pays that toll on every interaction. The crossing is cheap, but it is not free, and it happens constantly.

Strings are awkward for the same reason. There is no shared string type, so text is encoded, copied and decoded at the boundary. A workload that is mostly string manipulation can spend more time marshalling than computing.

Short jobs rarely pay the investment back. Download, compile and instantiate all cost real time. If the computation itself takes milliseconds, the module never recovers that.

And when the platform already does the job in native code, shipping your own copy is a step backwards. WebCodecs encodes and decodes video. Web Audio handles audio graphs. Canvas decodes, resizes and re-encodes images. Wrapping a resampler in a megabyte of WebAssembly to do what drawImage already does is the clearest version of the mistake.

One more thing is less a limitation than a trap. WebAssembly calls are synchronous. A long-running call blocks whatever thread it is on, and on the main thread that means a frozen page, exactly as a long JavaScript loop would. Heavy modules belong in a Worker. That is not optional advice.

Questions worth answering first

  • Does a mature native library already solve this correctly? That is the strongest reason to compile it, and the weakest reason to reimplement it in JavaScript.
  • Is the work numeric, self-contained, and long enough to amortise the download and the compile? Tight loops over typed buffers are the shape that wins.
  • Does a browser API already cover it? If WebCodecs, Web Audio or canvas handles the case, use them and ship nothing.
  • Can the binary wait until the user asks for the operation, and can you live with the memory the instance will hold for the rest of the tab's life?
  • Can it run in a Worker? If it has to run on the main thread and takes longer than a frame, the fix is to move it, not to optimise it.

The technology becomes undramatic once you stop expecting it to replace JavaScript. It is a way to run existing native code inside a sandbox that was never designed for it, with a memory model you have to respect and a download you have to pay for. Treat those two costs as design constraints from the first day and the rest is ordinary engineering.