DEV Community

Lank_M
Lank_M

Posted on

Base64-inlined WASM still costs 37% more after gzip

Emscripten can bake your .wasm into the JavaScript loader as a base64 string. One file, no MIME config, no locateFile headaches. I work on the in-browser codec loading at imging, and our HEIC decoder ships exactly this way. This week I finally measured what that convenience costs after compression. The short answer: about 140 KB more on the first load, and gzip does not claw it back.

The file in question

The decoder is open-source libheif 1.19.8 plus libde265, compiled to WASM. We did not write the decoder. What we own is the loading path: try the browser's native decoder first, and only fetch the WASM when that fails. The raw WASM is 1,034,305 bytes. Inlined as base64 it becomes 1,379,076 characters, and the whole libheif-bundle.mjs is 1,461,926 bytes. Production serves it gzipped at 520,705 bytes.

The numbers below come from a local copy of the bundle that imging serves in its 09-03 build. I measured it on an Apple M4 with Node 24's built-in zlib, comparing the inlined file against the same bytes split back into a bare .wasm plus the leftover glue JS.

What base64 does to compression

Base64 inflates size by 4/3 before compression, and most people assume gzip will squeeze that back out. I assumed so too. Here is the script I ran in the bundle's directory:

import * as fsys from 'node:fs';
import { brotliCompressSync, gzipSync } from 'node:zlib';

const src = fsys.readFileSync('libheif-bundle.mjs', 'latin1');
const [b64] = src.match(/[A-Za-z0-9+/=]{100000,}/);
const parts = [Buffer.from(b64, 'base64'), Buffer.from(src.replace(b64, ''), 'latin1')];
const acc = new Map();
for (const [name, pack] of [['gzip', gzipSync], ['brotli', brotliCompressSync]]) {
  acc.set(name, {
    inline: pack(Buffer.from(src, 'latin1')).length,
    split: parts.reduce((n, p) => n + pack(p).length, 0),
  });
}
console.log(Object.fromEntries(acc));
Enter fullscreen mode Exit fullscreen mode

It printed gzip: { inline: 517971, split: 378166 } and brotli: { inline: 388879, split: 287053 }. Split files are 139,805 bytes smaller under gzip, which means the inlined version costs 37% more on the wire. Brotli narrows the absolute gap to 101,826 bytes but does not close it. Production only serves gzip today (a br request gets the uncompressed file back), so the brotli pair is hypothetical.

The reason is alignment. Base64 maps every 3 input bytes to 4 characters. A repeated byte sequence in the WASM lands at one of three offsets relative to those 3-byte groups, so it turns into one of three different character strings. Deflate only matches literal repeats, and a lot of them stop being literal. Compressed alone, the base64 string is 489,570 bytes while the raw WASM is 350,473.

Bytes transferred when the HEIC decoder loads: unpacked size, inlined WASM, first transfer, and revisit

Look at the last two bars. The first load transfers 522,557 bytes across both files, and by my local math over 100 KB of that is the base64 tax. A reload costs 1,095 bytes, because the files are served with max-age=0, must-revalidate and come back as two 304s. So the penalty lands once per user, not per visit.

Is streaming compilation the bigger loss?

Inlining also rules out WebAssembly.compileStreaming, since there is no separate response to stream. I expected that to matter. This bundle uses synchronous new WebAssembly.Module, and in Chromium 149 that call took 0.9 to 1.2 ms over five runs. Most of that speed is V8's lazy compilation: with --no-wasm-lazy-compilation the same call took 6.9 ms, because function bodies are otherwise compiled on first call. So it is not the full compile cost. It does suggest the bytes are the real bill here, not the compile. Decoding is heavier than either: a 12MP image took 377 ms on the main thread in the same headless Chromium build, on a synthetic sample I encoded with macOS sips.

Two more caveats. My local gzip-6 output is 2,734 bytes smaller than what production actually sends, and I don't know which gzip level the server uses. I also have not deployed a split build, so "split saves 140 KB" is arithmetic, not a measured transfer.

Only a subset of users pay this at all. The decoder downloads only when native decoding fails, which in my runs meant Playwright's Chromium 149 and Firefox 151 builds. Playwright's WebKit 26.5 build on macOS decoded the HEIC natively and never fetched it.

If you ship an Emscripten single-file build, run the script above against your own bundle. Compare inline to split before deciding whether the deployment simplicity is worth the extra bytes.

Top comments (0)