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' },
    ],
  },
};
Which mechanism handles your module Asynchronous WebAssembly imports the module's exports directly and suits bundler-target glue. Asset modules hand back a URL or bytes for code that instantiates the module itself. Synchronous WebAssembly is a legacy compatibility path. webassembly/async import { f } from './m.wasm' bundler-target glue Webpack instantiates it use this by default asset/resource import url from './m.wasm' you fetch and instantiate web-target glue, workers use this for control syncWebAssembly legacy Webpack 4 behaviour blocks on instantiation size-limited by the platform compatibility only

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.

Each error names a different setting A parse failure means the rule or experiment is missing, an initial-chunk error means a synchronous import, a 404 means the public path is wrong, and a MIME type error is a server configuration rather than a bundler one. magic header not detected missing experiment or rule included in initial chunk import it dynamically 404 on the .wasm publicPath, or 'auto' Read the message rather than searching for the symptom — Webpack's WebAssembly errors are unusually specific about which setting is at fault.

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.

asyncWebAssembly against an asset module With asyncWebAssembly the binary becomes a module webpack resolves and instantiates. As an asset module it is emitted as a file and fetched at runtime, which keeps the entry chunk small. experiments.asyncWebAssembly entry chunk with glue binary imported as a module asset/resource + manual fetch small entry binary fetched on demand at a hashed URL Pick the asset-module route when the binary is large or optional — nothing downloads it until the feature is used. Either way, set publicPath correctly or the fetch resolves against the page URL and 404s on a nested route.

Gotchas

  • Experiment flag missing. The most common failure, and the error mentions loaders rather than WebAssembly.
  • publicPath hard-coded to /. Breaks any deployment not served from the root.
  • Worker path as a string. Not analysed; use new URL(..., import.meta.url).
  • asyncWebAssembly with web-target glue. Mismatched expectations; use an asset module instead.
  • topLevelAwait disabled. The generated glue fails to parse with an error about await.
  • 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.

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