Inlining Small Wasm Modules as Base64
This page answers one question: should a small WebAssembly module ship as its own file or be embedded inside the JavaScript that uses it — and if embedding is right, how do you do it without paying more than you save?
Prerequisites
- [ ] A built module and its size in bytes (
wc -c module.wasm), both raw and compressed. - [ ] A bundler or build step you control: wasm-pack, Emscripten, Vite, Rollup or esbuild.
- [ ] Some idea of how the module is used — on every page load, or only on some interactions.
What inlining trades
A separate .wasm file costs one HTTP request and enables streaming compilation: the engine starts compiling
while the bytes are still arriving, and it can cache the compiled machine code against the file’s URL for the
next visit. An inlined module costs nothing in requests, but the bytes travel inside the JavaScript as base64
text, which is a third larger, must be decoded into a Uint8Array before compiling, and cannot be streamed —
the engine sees the binary only after the whole script has downloaded and run far enough to decode it.
For a module of a few kilobytes those costs are tiny and the saved request — a round trip, connection scheduling, a separate cache entry — dominates. For a module of a few hundred kilobytes the costs dominate. Somewhere between is a crossover, and it is lower than many people assume once compression is accounted for.
The last row is the one people forget. An inlined module is part of the JavaScript bundle’s cache entry, so a one-line change to any code in that bundle invalidates the module too, and every returning visitor downloads it again. A separate file with a content hash survives every deploy that did not change it.
Step 1 — measure the compressed sizes
Base64 inflates bytes by 4/3, but compression claws much of that back because base64’s 64-character alphabet compresses reasonably well. Measure rather than guess:
wc -c < math.wasm
brotli -q 11 -c math.wasm | wc -c
base64 -w0 math.wasm > math.b64 && brotli -q 11 -c math.b64 | wc -c
6144 # raw module
2911 # brotli, as a separate file
3420 # brotli, as base64 inside JS
Here inlining costs about 500 extra bytes over the wire — less than the size of the HTTP request and response headers for a separate file. For this module, inlining is a clear win.
Step 2 — inline with the tool you already use
With wasm-pack there is no built-in inline mode, but bundlers do it at import time. In Vite, the ?inline
suffix is not applied to .wasm by default; use the ?url asset with a size limit, or the explicit
vite-plugin-wasm with an inline threshold. Rollup’s @rollup/plugin-wasm accepts maxFileSize, and esbuild’s
binary loader inlines unconditionally:
// rollup.config.mjs — inline anything under 8 KB, emit the rest
import { wasm } from "@rollup/plugin-wasm";
export default {
input: "src/main.js",
output: { dir: "dist", format: "es" },
plugins: [wasm({ maxFileSize: 8192 })],
};
Emscripten has a dedicated flag that embeds the binary in the generated loader:
emcc -O3 crc32.c -sSINGLE_FILE=1 -sMODULARIZE -sEXPORT_ES6 \
-sEXPORTED_FUNCTIONS=_crc32 -o crc32.mjs
SINGLE_FILE produces one .mjs with the module as base64, which is convenient for a small utility that must be
dropped into a page or a worker with no asset handling at all.
Step 3 — or write the decode yourself
For a hand-written module, inlining manually takes a few lines and makes the cost visible:
// generated at build time: const WASM_B64 = "AGFzbQEAAAABBwFgAn9/AX8DAgEABwcBA2FkZAAACgkBBwAgACABagsA";
import { WASM_B64 } from "./math.b64.js";
function decode(b64) {
if (typeof Uint8Array.fromBase64 === "function") return Uint8Array.fromBase64(b64);
const bin = atob(b64);
const out = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
return out;
}
const module = await WebAssembly.compile(decode(WASM_B64));
const instance = await WebAssembly.instantiate(module, {});
export const add = instance.exports.add;
Uint8Array.fromBase64 is the newer, faster built-in; the atob loop is the fallback. Either way, keep the
decode out of hot paths — do it once at module load, not per call.
Step 3b — guard against accidental inlining
Inlining is safe when it is a deliberate choice for a small module, and expensive when it happens by accident to a
large one. Both happen in real projects: a plugin with an “inline everything” default, a SINGLE_FILE flag copied
from a demo into a release build, or a module that was 4 KB when the threshold was set and is 90 KB a year later.
None of those produce an error. The page still works; it just loads more slowly for everyone.
A short check in the build catches it. Search the output bundles for long base64 runs that start with the
WebAssembly magic number — AGFzbQ is base64 for \0asm — and fail if any exceeds your threshold:
#!/usr/bin/env bash
# fail the build if any inlined module exceeds 8 KB
limit=8192
for f in dist/assets/*.js; do
grep -oE 'AGFzbQ[A-Za-z0-9+/=]{100,}' "$f" | while read -r b64; do
bytes=$(( ${#b64} * 3 / 4 ))
if (( bytes > limit )); then
echo "✗ $f inlines a ${bytes}-byte Wasm module (limit ${limit})"; exit 1
fi
done
done
echo "✓ no oversized inlined modules"
The same check doubles as documentation: anyone reading the build script learns that inlining is intentional, and where the line is. Pair it with the size tracking from catching size regressions in CI, which watches the module itself, so growth is caught whether or not it is inlined.
Step 4 — find your crossover
The right threshold depends on network conditions and on how the module is used, but measurements on typical connections put it lower than the 8–10 KB defaults many plugins choose for images. Two effects push it down: the compiled-code cache only applies to modules loaded from a URL, and the inlined copy is re-downloaded with every JavaScript change.
A practical rule: inline modules under about 4 KB compressed that are used on every page and change about as often as the code around them. Emit everything else. If a module is loaded on interaction rather than at startup, emit it regardless of size — there is no startup request to save, and lazy loading it as described in lazy loading Wasm on first use keeps it out of the initial bundle entirely.
Expected output
After switching a 6 KB checksum module to inline, the network waterfall loses one request and the module is ready as soon as the script finishes:
before: index.js 41.2 KB (br) + math-3c91a2.wasm 2.9 KB (br) 2 requests
after: index.js 44.6 KB (br) 1 request
In the Performance panel, the compile appears as a short v8.wasm.compile slice inside the script’s own
execution rather than as a separate task after a network event.
Gotchas
- Content Security Policy blocks compilation. Compiling from bytes requires
'wasm-unsafe-eval'inscript-srcjust as streaming does; see writing a Content Security Policy for Wasm. - Chrome’s main-thread size limit. Synchronous
new WebAssembly.Module(bytes)on the main thread is limited to small modules (4 KB in Chromium). Use the asyncWebAssembly.compilefor anything inlined. - Inlining a large module by accident. A plugin default of “inline everything” or
SINGLE_FILEleft on in a release build can quietly put hundreds of kilobytes into the main bundle. Add a size check. - Source maps and debugging. An inlined module has no URL, so DevTools labels it with a generated name. DWARF-based debugging still works, but the module is harder to find in the Sources panel.
Performance note
Across modules of different sizes on a throttled 4G profile, inlining was faster up to about 5 KB compressed, roughly even around 8 KB, and increasingly slower beyond — by 230 ms for a 120 KB module, because compilation could not overlap the download and the compiled-code cache never applied.
Frequently Asked Questions
Does inlining help in a service-worker-cached app? Less. Once both files come from the service worker cache, the request cost disappears and only the costs of inlining remain. Emit modules in installable apps.
Is base64 the only option?
It is the most compatible. Some tools emit the bytes as a JavaScript array literal, which is larger; a few use
Uint8Array.fromBase64 with Z85 or other encodings, which saves little after compression.
Does HTTP/2 or HTTP/3 change the answer? It lowers the cost of the extra request, because it no longer needs its own connection, which pushes the crossover down further. It does not change the streaming or caching arguments at all.
What about inlining in a worker? Same trade-offs, with one advantage: a worker script is usually loaded once and rarely changes independently of the module, so the cache-invalidation cost is smaller.
Related
- Loading Wasm in Rollup and esbuild — configuring the inline threshold.
- Streaming instantiation vs ArrayBuffer instantiation — what inlining gives up.
- Instantiating small modules synchronously — the size limit on synchronous compilation.
- Compressing Wasm with Brotli for delivery — measuring the compressed sizes that decide this.
← Back to ESM Bindings & Module Generation