Code-Splitting Several Wasm Modules in One App

This page answers one task: an application uses several WebAssembly modules — an image codec, a PDF renderer, a search engine, a spell checker — and you want each page or feature to download and compile only the modules it actually uses, with likely-next modules ready before the user needs them.

Prerequisites

  • [ ] A bundled application (Vite, webpack, Rollup or esbuild) with routes or feature boundaries.
  • [ ] Wasm modules loaded through glue that uses new URL(..., import.meta.url) or bundler-recognised imports.
  • [ ] A bundle analyser and the Network panel to verify results.

Why several modules multiply the problem

One WebAssembly module of a few hundred kilobytes is easy to accommodate. An application with five — some several megabytes, like a PDF renderer or an ML runtime — cannot load them all up front without hurting every page: downloads compete with critical resources, compilation competes with the main thread, and memory grows by the sum of all instances. Most users of most pages need none or one of them.

Bundlers already split JavaScript by dynamic import(); the job is to make WebAssembly follow the same boundaries. Each module’s glue should sit behind the dynamic import of the feature that uses it, so the .wasm asset is requested only when that feature loads. Modules that share a runtime — several Emscripten builds, several wasm-bindgen crates — need care to avoid shipping duplicate glue or, worse, duplicate copies of a large shared library.

Which modules each route loads The home page loads no Wasm. The editor loads the image codec. The documents page loads the PDF renderer. Search loads the search engine when the box is focused. The spell checker loads only when editing text. Each module is a separate asset fetched by the feature that needs it. route / feature modules loaded when home none — photo editor image codec on route load documents PDF renderer on route load search box search engine on focus (preload) text editing spell checker on first keystroke

Step 1 — put each module behind its feature’s dynamic import

Organise code so that the only static importer of a module’s glue is the feature module that uses it, and load that feature dynamically:

// router.js
const routes = {
  "/editor": () => import("./features/editor/index.js"),        // imports the image codec's glue
  "/docs": () => import("./features/docs/index.js"),            // imports the PDF renderer's glue
};
// features/editor/index.js
import initCodec, { decode } from "../../wasm/codec/codec.js";
await initCodec();                                              // fetches codec_bg.wasm via import.meta.url
export function openImage(bytes) { return decode(bytes); }

The bundler emits one chunk per feature and one .wasm asset per module, and the asset is referenced only from its feature chunk. Shared code — a generic loader utility — goes in a common chunk without pulling in any module.

Step 2 — verify with the bundle analyser and Network panel

Run the bundler’s analyser (vite-bundle-visualizer, webpack-bundle-analyzer) and confirm that no .wasm asset is referenced from the entry chunk. Then load each route with the Network panel open and check which .wasm files are requested. Common mistakes show up immediately: a shared utility module that re-exports a module’s glue, a layout component that imports a feature module statically, or a barrel file (index.js re-exporting everything) that pulls every module into every page.

Step 3 — preload what the user will probably need next

Lazy loading moves the wait from page load to first use; preloading hides it. When the user hovers over a link to the editor, focuses the search box, or idles on a page whose next step is predictable, start loading the next module:

searchInput.addEventListener("focus", () => import("./features/search/index.js"), { once: true });
link.addEventListener("pointerenter", () => import("./features/editor/index.js"), { once: true });
requestIdleCallback(() => import("./features/spellcheck/index.js"));

The dynamic import starts fetching both the chunk and, when its top level runs, the module. For modules whose glue initialises lazily, call the initialiser in the preload too, so compilation happens before the user clicks. Speculation rules can prefetch the next page’s assets for multi-page apps, as described in prefetching Wasm for the next page.

A feature's Wasm module from hint to use A user interaction hints that a feature is likely. The app preloads the feature's chunk, which fetches and compiles its Wasm module in the background. When the user opens the feature, the module is already compiled, and only instantiation or the first call remains. hint hover, focus, idle import(feature) chunk + glue fetch + compile .wasm in the background user opens feature module ready first call no visible wait

Step 4 — avoid duplicate runtimes

Several modules built with the same toolchain each carry their own runtime: every Emscripten module includes its own copy of libc pieces and glue, and every wasm-bindgen crate carries its own allocator and glue. That duplication is usually small and acceptable, but becomes significant when several modules embed the same large library — two modules each statically linking the same image library, say. Options: merge closely related modules into one built from a shared crate or library, keep them separate when they are used on different routes, or use dynamic linking for a shared library only when the size savings justify its complexity, as discussed in linking side modules at runtime. Measure before merging: two 400 KB modules on different routes are better than one 700 KB module on both.

Step 5 — release memory when features close

Each instantiated module holds its own linear memory, which never shrinks. Long-lived single-page applications that open the PDF viewer, then the editor, then search, accumulate all three instances’ memory. For heavy features used occasionally, run the module in a worker created when the feature opens and terminated when it closes, so its memory is released, as described in why Wasm memory never shrinks. Keep frequently used modules alive to avoid recompiling.

Caching across deployments

Splitting interacts with caching. Content-hashed asset names let browsers cache each module indefinitely, and a deployment that changes only the PDF renderer invalidates only its asset; users keep the cached codec and search modules. That benefit depends on the modules’ build outputs being stable: if every deployment rebuilds every module with embedded timestamps or paths, every hash changes and every user downloads everything again. Reproducible builds, as in producing reproducible Wasm binaries, keep unchanged modules byte-identical across deployments, so their cached copies stay valid.

Measuring what each page costs

Make the result measurable: record, per route, the WebAssembly bytes downloaded and the compile time spent, in development with automated tests and in production with real-user monitoring. A budget per route — “home: 0 KB of Wasm; editor: ≤ 400 KB” — checked in CI prevents a future import from silently attaching a module to a page that does not need it. Playwright tests that visit each route and sum the .wasm responses are enough to enforce it.

Server-rendered and multi-page applications

Splitting looks different outside single-page apps. In multi-page applications and server-rendered frameworks, each page has its own entry, so modules naturally load only where pages import them — provided shared layout scripts do not import feature code. The risk shifts to repeated work: each page load fetches (from cache) and compiles its modules again, unless the browser’s code cache serves compiled code, which it typically does for unchanged, content-hashed files fetched from the same origin. Keep module URLs stable across pages and deployments to benefit. For server-rendered frameworks, make sure modules are imported only in client components or browser-only code paths, so server builds do not try to load them, and so the client manifest lists each module only for the routes that use it.

Expected output

The home page downloads no WebAssembly; the editor route downloads only codec.[hash].wasm; focusing the search box preloads the search engine so the first query runs without a visible wait; closing the PDF viewer terminates its worker and releases about 180 MB; and a CI test fails if any route exceeds its Wasm budget.

Gotchas

  • Barrel files re-exporting glue. Every importer pulls every module. Import modules only from their feature.
  • Static imports from layouts. Shared layouts load modules on every route. Keep them out of layouts.
  • Lazy loading without preloading. The wait moves to first use. Preload on likely intent.
  • Duplicated large libraries. Two modules embedding the same library double its size. Merge or share deliberately.
  • Instances accumulating memory. Run occasional heavy modules in disposable workers.

Performance note

Before splitting, every page of one application downloaded 3.1 MB of WebAssembly (compressed) and spent 410 ms compiling on a mid-range phone. After splitting per feature, the home page downloaded none, and the heaviest route downloaded 1.2 MB.

WebAssembly downloaded on the home page Kilobytes of compressed WebAssembly downloaded when loading the home page of an application with five modules, before splitting with all modules in the entry chunk, and after splitting per feature. KB of Wasm on the home page all modules in entry chunk 3,100 KB split per feature 0 KB

Frequently Asked Questions

Can a bundler split a single large .wasm file? No — the binary is opaque. Split at build time with tools like wasm-split, or build separate modules.

Does each dynamic import compile the module again? No — module-graph caching and the browser’s code cache reuse compiled code within and across visits.

Should small modules be inlined instead? Tiny modules can be inlined as base64 in their feature chunk, avoiding a request; see the inlining guide.

How do workers fit in? Load a feature’s module inside its worker; the worker script is itself a separate chunk, loaded only when the feature starts it.

Does splitting matter for multi-page apps? Yes — keep feature modules out of shared layout scripts, and rely on stable URLs so the browser’s code cache avoids recompiling on each page.

Can split Wasm modules share one memory? Yes, if built for it, but separate memories are simpler unless modules exchange large data.

← Back to ESM Bindings & Module Generation