Bundling Wasm with Webpack 5
This guide answers one task: make Webpack 5 emit and load a .wasm file correctly — in development, in a
production build, and from a worker — without the module ending up inlined, missing or fetched from the
wrong path.
Prerequisites
- [ ] Webpack 5.90 or later.
- [ ] A module built with
wasm-pack --target bundler, or your own ES module glue. - [ ] A production build you can inspect, not just a dev server.
- [ ] Ten minutes; most of the difficulty here is knowing which of three mechanisms applies.
Three mechanisms, and which one you want
Webpack can handle a .wasm in three ways, and the error messages do not make clear which one is active.
Asynchronous WebAssembly treats the module as an ES module whose exports you import directly. It is
enabled by an experiment flag, it is what wasm-pack --target bundler output expects, and it is the
option you want in almost every case.
Synchronous WebAssembly is the legacy Webpack 4 behaviour, kept behind another flag. It blocks on instantiation, which the platform only permits for small modules, and it exists for compatibility rather than for new work.
Asset modules treat the .wasm as an opaque file and give you a URL or the bytes. That is what you
want when you instantiate the module yourself — for a worker, or for wasm-pack --target web output.
// webpack.config.js
module.exports = {
experiments: { asyncWebAssembly: true },
module: {
rules: [
{ test: /\.wasm$/, type: 'webassembly/async' },
],
},
};
The working configuration
For a wasm-pack --target bundler package, this is the whole configuration:
const path = require('path');
module.exports = {
mode: 'production',
entry: './src/index.js',
experiments: {
asyncWebAssembly: true,
topLevelAwait: true, // the generated glue uses it
},
output: {
path: path.resolve(__dirname, 'dist'),
filename: '[name].[contenthash].js',
assetModuleFilename: 'assets/[name].[contenthash][ext]',
publicPath: '/', // must match where you actually serve from
clean: true,
},
module: {
rules: [
{ test: /\.wasm$/, type: 'webassembly/async' },
],
},
};
publicPath is the setting that decides whether the module is found in production. Webpack generates the
fetch URL for the .wasm from it, so a build served from a CDN path with publicPath: '/' requests the
binary from the wrong origin and fails with a 404 that names a path you never wrote.
Setting publicPath: 'auto' derives it from the script’s own URL at runtime, which is usually what a
CDN-hosted build wants and avoids hard-coding a host into the bundle.
Instantiating it yourself
When you want control — a worker, a lazy load, a custom import object — use an asset module and the
web-target glue.
// webpack.config.js
{ test: /\.wasm$/, type: 'asset/resource' }
import wasmUrl from '../engine/pkg/engine_bg.wasm';
import init, { process } from '../engine/pkg/engine.js';
let ready = null;
export function engine() {
ready ??= init({ module_or_path: wasmUrl }).then(() => ({ process }));
return ready;
}
This is the arrangement to prefer for anything loaded conditionally, because Webpack emits the binary as a separate asset with a content hash and the import of the glue can be dynamic — so nothing is fetched until the feature is used.
Workers
Webpack 5 understands the new Worker(new URL(...), { type: 'module' }) form and bundles the worker
separately, including any .wasm it imports.
// main.js
const worker = new Worker(new URL('./engine.worker.js', import.meta.url), { type: 'module' });
worker.postMessage({ input });
// engine.worker.js
import init, { process } from '../engine/pkg/engine.js';
const ready = init();
self.onmessage = async ({ data }) => {
await ready;
self.postMessage({ result: process(data.input) });
};
The new URL(..., import.meta.url) form is required — a string path is not analysed and the worker file is
not emitted, producing a 404 at runtime. This is the single most common worker bundling mistake and it
behaves identically across bundlers, so the habit transfers.
Development versus production behaviour
A configuration that works with the dev server and fails in a production build is a common and confusing outcome, because the two paths differ in ways that matter here.
The dev server serves from memory with its own middleware, which sets the content type correctly and
resolves paths relative to the server root regardless of publicPath. A production build writes files to
disk and relies on whatever serves them, so both the content type and the path become your responsibility.
Source maps behave differently too. In development, the glue is unminified and errors point at real lines; in production with minification, an error inside the generated glue reports a position in a minified file that tells you nothing. Emitting source maps for the production build — even if you do not serve them publicly — makes a production-only failure diagnosable.
module.exports = {
mode: 'production',
devtool: 'source-map', // upload these, do not necessarily serve them
devServer: {
headers: {
'Cross-Origin-Opener-Policy': 'same-origin',
'Cross-Origin-Embedder-Policy': 'credentialless',
},
},
};
Those headers in devServer matter if the module uses threads: without them the development environment
silently runs single-threaded, and the difference between development and production performance becomes
inexplicable. Setting them in both places keeps the two environments comparable, which is the general
principle worth applying to every setting in this section.
Always verify against the real production build served by the real server before concluding that a configuration works. A dev server is a convenience, not a simulation.
Reading the errors
Each misconfiguration produces a characteristic message, and recognising them saves a search.
Module parse failed: magic header not detected
You may need an appropriate loader to handle this file type
Webpack is treating the .wasm as JavaScript: the experiment flag or the rule is missing.
WebAssembly module is included in initial chunk.
This is not allowed, because WebAssembly download and compilation must happen asynchronously.
A synchronous import of an asynchronous module in the initial chunk. Import it from a dynamically imported
module, or enable topLevelAwait and ensure the entry is treated as async.
GET https://cdn.example.com/engine_bg.6b03d1.wasm 404 (Not Found)
publicPath does not match the deployment. Set it to 'auto' or to the real base URL.
TypeError: Failed to execute 'compileStreaming': Incorrect response MIME type
Not a Webpack problem at all — the server is not sending application/wasm, as covered in
serving Wasm files with the right headers.
Expected output
A production build emits the binary as its own hashed asset, separate from the JavaScript:
npx webpack --mode production
asset assets/engine_bg.6b03d1.wasm 96.7 KiB [emitted] [immutable]
asset main.8f1a2c.js 42.1 KiB [emitted] [immutable] [minimized]
asset 412.1c9d4e.js 3.4 KiB [emitted] [immutable] [minimized]
webpack 5.94.0 compiled successfully in 2841 ms
Three assets is the shape you want: the entry bundle, a lazily loaded chunk containing the glue, and the
binary. A build where the .wasm does not appear as its own asset has inlined it, which loses caching and
streaming compilation.
Gotchas
- Experiment flag missing. The most common failure, and the error mentions loaders rather than WebAssembly.
publicPathhard-coded to/. Breaks any deployment not served from the root.- Worker path as a string. Not analysed; use
new URL(..., import.meta.url). asyncWebAssemblywithweb-target glue. Mismatched expectations; use an asset module instead.topLevelAwaitdisabled. The generated glue fails to parse with an error aboutawait.- Inlining via a small
maxSize. Check the emitted assets rather than assuming.
Performance note
For a 96.7 kB module, the production build emitted it as a separate immutable asset fetched in parallel with the entry bundle, adding no measurable time to first paint because the import was dynamic. Inlining the same module as a base64 asset — which happens when a size threshold is set too high — grew the entry bundle by 129 kB, blocked parsing until it was downloaded, and removed streaming compilation entirely.
Frequently Asked Questions
Should I use Webpack or something newer? Webpack handles this well and is what a great many projects already use. Vite, Rspack and others handle it with less configuration; if you are choosing freshly, that is a point in their favour, and if you are already on Webpack this is a configuration block rather than a reason to migrate.
Does Rspack work the same way? Closely enough that the configuration above transfers with minimal change, since it implements Webpack’s API. Verify the emitted assets after switching, as always.
How do I keep the module out of the initial bundle? Import the glue dynamically from the code path that needs it. Webpack then creates a separate chunk and neither the glue nor the binary is fetched until that path runs.
Related
- Bundling Wasm ESM with Vite — the same job in another bundler.
- Publishing a Wasm package to npm — producing a package these configurations consume.
- Loading Wasm in a Web Worker with ESM — the worker case in depth.
When a bundler configuration finally works, write down why in a comment above it. Six months later the next person to touch it will otherwise remove the line that made it work.
The configuration above has been stable across Webpack 5 minor releases and is a reasonable starting point for any project.
← Back to ESM Bindings & Module Generation