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.

A separate file versus an inlined module A separate .wasm file costs one request but compiles while downloading and gets its own code cache entry. An inlined module saves the request but grows by a third, is decoded in JavaScript, and compiles only after the script has loaded. separate .wasm file one extra request streaming compilation during download compiled-code cache keyed by URL cached independently of JS changes wins for anything non-trivial inlined base64 no extra request 33% larger before compression decode, then compile, after script runs re-downloaded whenever the JS changes wins only for tiny modules

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.

What happens to an inlined module at load time The JavaScript bundle downloads in full, the script runs and reaches the base64 string, the string is decoded into bytes, and only then can WebAssembly.compile start. None of these steps overlaps with the download. bundle downloads module inside, +33% raw script executes reaches the string base64 decode fromBase64 or atob loop WebAssembly.compile no streaming possible A separate file lets the last step start during the first; inlining makes them strictly sequential.

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' in script-src just 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 async WebAssembly.compile for anything inlined.
  • Inlining a large module by accident. A plugin default of “inline everything” or SINGLE_FILE left 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.

Inlined minus separate-file time to ready, by module size Difference in milliseconds between inlined and separate-file loading on a throttled 4G connection. Negative values mean inlining was faster. The crossover is around eight kilobytes compressed. ms difference (positive = inlining slower) 2 KB module 4 ms 6 KB module 9 ms 16 KB module 42 ms 48 KB module 118 ms 120 KB module 231 ms Below about 5 KB inlining saved 20-40 ms by avoiding a request; the chart shows the cost above that, which grows with size.

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.

← Back to ESM Bindings & Module Generation