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.
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.
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.
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.
Related
- Splitting a Wasm module for lazy loading — splitting inside one module.
- Exposing Wasm from a library with a clean ESM API — side-effect-free libraries.
- Lazy loading Wasm on first use — the single-module case.
- Bundling Wasm ESM with Vite — bundler configuration.
← Back to ESM Bindings & Module Generation