Inlining WASM in html might not be that terrible

Try the Pyodide REPL as a single HTML file here! (for now only works on non-Chromium browsers)

Most WASM that currently runs in browsers is served as a separate file. This doesn’t have to be the only way, however. What if you could inline it similar to how Javascript can be inlined? You would pack everything in a single HTML file, and only have to ship that one file.

What complicates inlining WASM is that it is a binary blob. You need to base64 encode it to “inline” it in an HTML file. But methods like Webassembly.Instantiate don’t accept base64 directly. Fortunately, since around December 2024 Firefox and Safari have the Uint8Array.fromBase64() method. The Ladybird browser also offers this method. And for the other browsers that don’t have it, we could load a polyfill or rely on window.btoa.

To get a feel for the performance of a real WASM project when inlined, I chose Pyodide (CPython compiled to WASM). I prettified a Pyodide build, debugged it and looked for where the code downloaded and instantiated WASM objects and some other binary objects. In those places I referred to a base64 string instead. This Javascript, now containing base64 strings, is then inlined into the HTML file.

The automated hack to do this for the Pyodide project is in this Python script. This script is definitely not a universal tool for inlining WASM, and I don’t even plan to maintain it for Pyodide builds!

One piece of code before and after the inlining looked like this:

- let n;
- i
-   ? (n = await WebAssembly.instantiateStreaming(i, r))
-   : (n = await WebAssembly.instantiate(await t, r));
+ let n = await WebAssembly.instantiate(Uint8Array.fromBase64(
+   "base64 string of pyodide.asm.wasm omitted here"
+ ), r);

In another place I replaced a zip download with an inlined version of the zip file:

- async function G(e, t) {
+ async function G(e, t) {
+   if (e.endsWith('python_stdlib.zip')) { return Uint8Array.fromBase64(
+     "base64 string of python_stdlib.zip omitted here"
+   }

A picture of console.html with inline base64 encoded WASM, that shows that it contains a lot of repeated A characters My gut feeling said that this inlined WASM might compress quite well.

I didn’t benchmark it thoroughly, but seeing it in action I’m not disappointed. Especially when the server can send a compressed version of the HTML, the startup time and memory footprint aren’t negatively impacted that much. The console.html file with everything inlined is 6 MB when brotli compressed, and almost 11 MB when gzipped. Without any compression it currently weighs around 30 MB.

But why? I think that it really can be a benefit to ship software as a single HTML file. It could be a solution if you want to run WASM, but you’re in an environment where you can only open HTML files that are on disk, and don’t have the possibility of serving them. Opening the unmodified console.html from a local filesystem will give you all kinds of CORS errors in your console:

This screenshot shows that CORS blocks requests going to protocols like file instead of http and https

But when everything is included in the same file, there are no other attempted requests. And no other requests means no CORS issues!

This screenshot shows the modified console.html file loading from the filesystem without CORS issues

So you can ship entire applications as a single HTML file, and all that people need to have is a web browser. Even in the most restrictive environments where you can’t install anything, you often do have a browser.

There is still a lot that can be done. To make the HTML smaller (before over-the-wire compression), I used Base122 encoding. This is an efficient encoding that uses UTF-8 characters, and only inflates the size of the encoded payload by around 14%. A variation of the WASM inlining script with base122 can be found here: inline_wasm_b122.py.

Getting a bit distracted I made the base122 Python package. There were already Python implementations of base122, but I wanted one that behaves like the builtin base64 package.

As a future project I would like to be able to ship the Jupyter Lite project as a single HTML file. Jupyter Lite is a variant of Jupyter that can use Pyodide as a kernel, and can be statically hosted. Merging this application into one HTML file will be more challenging, since it is a bigger and more complex application. The build setup also needs to be a lot less brittle than done here.

In this upcoming Jupyter Lite HTML file, I’ll also try to include all the Python packages that are included with Pyodide, so you’ll get a fully featured Python development environment in a single HTML file. Given the size of the current Pyodide builds, multiplied by the 1.14 factor of Base122 encoding, it will be at least around 400 MB.