WebAssembly Turned the Browser Tab Into a Real Computer
Last week I re-encoded a 300 MB screen recording into a 38 MB MP4 without leaving a browser tab. I kept DevTools open on the Network panel while it ran, half expecting to catch the page shipping my file off to a server. After the initial page load, the panel showed nothing new. One CPU core pinned near 100 percent, the laptop fan spun up, and two minutes later the tab offered me a download. The recording stayed on my disk the whole time.
A decade ago that workflow meant installing HandBrake, or uploading the file to a stranger's server and waiting twice, once up and once down. WebAssembly is what changed the arithmetic. I went deep on it while working out what Toolium should and should not use it for, enough to describe both the mechanism and the bills, and the bills are the part most write-ups skip.
A second instruction set for the browser
WebAssembly (WASM from here on) is a compact binary instruction format. A .wasm file holds low-level operations for a stack machine: loads and stores, integer and float arithmetic, comparisons, structured branches. The browser validates the module, compiles it to native machine code, and runs it inside the same sandbox as JavaScript, in the same process, under the same memory limits. A module cannot open a socket, read your filesystem, or reach into another tab; it can only use capabilities the surrounding JavaScript hands to it.
The speed comes from what the format leaves out. A JavaScript engine spends its life guessing: it watches a function run, bets on the types flowing through it, compiles an optimized version, and tears that version down the first time a string arrives where a number used to be. WASM shows up with its types declared. The engine compiles the whole module ahead of execution, and the compiled code holds. For arithmetic over large buffers, decoding pixels, quantizing DCT coefficients, mixing audio samples, the result lands close enough to native speed that the gap no longer decides what a tab can do.
You rarely write the format by hand. WASM is a compile target: Clang and Emscripten bring C and C++ to it, rustc has shipped wasm32 targets for years, and Go, Zig, and a long tail of smaller languages follow. The browser neither knows nor cares what the source language was. That property, more than raw speed, changed the web, because of what already existed in those languages.
Thirty years of C libraries walked in
The heaviest lifting in tabs today comes from code written long before WASM existed. mozjpeg is Mozilla's fork of libjpeg-turbo, a JPEG encoder tuned through a decade of production traffic; the Squoosh team compiled it with Emscripten and put a first-rate encoder inside a web page. libvips, the image library behind a large share of the world's thumbnail pipelines, exists as wasm-vips. ffmpeg.wasm wraps the whole FFmpeg project, demuxers, codecs, and filters included. pdfium, the PDF engine inside Chrome, compiles to WASM and rasterizes pages to pixels in a worker. On the Rust side, wasm-pack turns crates like image and resvg into npm packages with typed bindings.
Porting beats rewriting because these codebases hold decisions you do not want to rediscover. A production JPEG encoder is trellis quantization, chroma subsampling defaults, progressive scan scripts, and twenty years of bug reports from people with malformed files. A from-scratch JavaScript port would restart that clock at zero. Compiling the original means the browser build and the server build share the same fixes.
The wins are concrete. A tab can compress images before an upload, or instead of one, and it can rasterize a 60-page PDF for preview without a plugin. The transcode that opened this post was ffmpeg.wasm running an H.264 encode inside a worker while the page stayed responsive.
Toolium makes the opposite call, and that is the more useful example. Its image compressor runs entirely in your browser, but through a canvas-based library in a Web Worker rather than a WASM codec. For resizing and re-encoding a single photo, the plain-JavaScript path ran fast enough and shipped with no multi-megabyte codec to download first. The bytes still never reach my server; the privacy comes from staying on your machine, not from the engine that does the work.
I run the site alone, which I have written about on the about page, and the architecture follows from that. Receiving strangers' contracts and tax PDFs onto a server I administer at midnight is a liability I have no appetite for, and egress bandwidth for large files would run up a bill I would rather not pay. Running the work in your browser gives me the strong version of the privacy claim: the compressor has no upload endpoint, so the promise holds even if you distrust me.
What it costs
Four costs show up once you take this past a demo, and I hit each one while prototyping.
The download comes first. A mozjpeg build weighs a few hundred kilobytes, which disappears into page weight. ffmpeg.wasm is another animal: a core build with the common codecs runs to tens of megabytes, and a visitor on hotel Wi-Fi waits a long while before the first frame encodes. Browsers soften this. WebAssembly.instantiateStreaming compiles the module while the bytes arrive, and HTTP caching makes the second visit cheap, but cold-start weight still shapes the product, so the move is to fetch codecs lazily, at the moment a file is dropped, and keep anything heavyweight off the landing page.
Memory comes second, along with the copies. A module sees one linear block of memory, an ArrayBuffer it can grow but not reshape, and its pointers are integer offsets into that block. Your user's file lives on the JavaScript side as a File object, so before the encoder touches a byte you copy the whole thing into the WASM heap, and when it finishes you copy the result back out to build a Blob. For a 5 MB photo the copies are noise. For a 300 MB video you hold multiple copies of a large file inside a 32-bit address space capped at 4 GB, and real modules hit trouble well before the cap. Growing the heap can force the engine to allocate a fresh region and move everything, and memory a module claims tends to stay claimed until the page closes. Allocating up front where the size is knowable, and warning before a tool attempts anything enormous, keeps this in check.
The JavaScript boundary comes third. WASM code cannot touch the DOM, issue a fetch, or open a file picker; its imports and exports pass numbers. Anything shaped like a string or an object crosses through glue code, and the toolchains generate plenty of it: Emscripten emits a loader script, wasm-bindgen writes typed shims for Rust. The glue works, but each crossing has a toll, and a design that hops the boundary per pixel will lose to plain JavaScript arrays. Coarse calls win: hand the module a whole buffer, let it run for seconds, take a whole buffer back. That pattern makes fine-grained progress reporting awkward, which is why WASM tools so often show a spinner where a native app would show a percentage bar.
Threads come fourth, and this one stings. FFmpeg wants pthreads, pthreads on the web require SharedArrayBuffer, and after Spectre the browsers locked SharedArrayBuffer behind cross-origin isolation. Enabling it means serving Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp, which forces cross-origin scripts and iframes on the page to declare themselves through CORS or CORP headers. Plenty of third-party embeds decline to, ad scripts prominent among them. A site paying its hosting bill with ads has to choose between the multithreaded encoder and the ad revenue, and most keep the revenue. That means single-threaded builds and transcodes that run several times longer than the same FFmpeg would in a terminal.
Debugging earns an honorable mention. Chrome can step through C++ source when the module ships DWARF info, but a production stack trace still reads like wasm-function[1123]:0x8a41f, and I once spent an evening bisecting a crash that a native build would have explained in one gdb session.
Where it goes from here
Two proposals deserve attention without breathlessness. WASM GC shipped in Chrome and Firefox, and it lets garbage-collected languages target the browser without bundling their own collector; Kotlin and Dart compile to it now, and Flutter's web builds lean on it. If you write TypeScript for a living, your day changes little. The component model is the more ambitious project: typed, language-neutral interfaces so a Rust module and a Go module can compose without hand-rolled glue. Most of its momentum sits server-side around WASI, and I would not schedule a browser feature against it yet.
I remember the 2017 posts announcing the death of JavaScript, and I notice JavaScript still runs the page you are reading. What WASM delivered is narrower than the prophecy and more useful: encoders, codecs, parsers, and renderers that install nothing and upload nothing. My laptop spent two minutes at full fan transcoding a screen recording inside a browser sandbox, and the Network panel had nothing to report. I keep checking it anyway.