Where Wasm fits in a web app

WebAssembly (Wasm) is attractive when you have a CPU-intensive task that needs near-native speed in the browser. To make the pattern concrete, this guide uses factorial calculation β€” an arbitrarily expensive integer operation β€” as a stand-in for realistic workloads like barcode scanning or raster image tracing.

A well-optimized iterative C++ implementation of the factorial function compiles to a standalone Wasm module. In the browser, the module is loaded with fetch(), then compiled and instantiated before its exported factorial() function can be called:

const importObject = {};
const resultObject = await WebAssembly.instantiateStreaming(
  fetch('factorial.wasm'),
  importObject,
);
const factorial = resultObject.instance.exports.factorial;

button.addEventListener('click', (e) => {
  e.preventDefault();
  output.textContent = factorial(parseInt(input.value, 10));
});

The instantiation step deserves attention. Although the WebAssembly API offers WebAssembly.compile() and WebAssembly.instantiate() separately, the streaming variants WebAssembly.compileStreaming() and WebAssembly.instantiateStreaming() are preferable β€” they operate directly on a streamed fetch() response and avoid an unnecessary round-trip. Since the module is a hard dependency, the Wasm file should also be preloaded from the <head> with a CORS-enabled rel="preload" link so the download starts as early as possible.

The main-thread problem

Running expensive Wasm work directly on the main thread risks blocking the app's UI thread entirely. The standard mitigation is to move the computation into a Web Worker. This creates a small structural requirement: the main thread only forwards input to the worker and renders the output it receives back.

/* Main thread. */

let worker = null;

// When the button is clicked, submit the input value
//  to the Web Worker.
button.addEventListener('click', (e) => {
  e.preventDefault();

  // Create the Web Worker lazily on-demand.
  if (!worker) {
    worker = new Worker('worker.js');

    // Listen for incoming messages and display the result.
    worker.addEventListener('message', (e) => {
      output.textContent = e.result;
    });
  }

  worker.postMessage({ integer: parseInt(input.value, 10) });
});

Yet instantiating Wasm inside the worker introduces a concurrency hazard. WebAssembly.instantiateStreaming() is asynchronous, so a message from the main thread can arrive before the worker's module is ready and vanish without a handler. The fix is to capture the asynchronous work as a promise rather than awaiting it at module top level. The event listener is registered immediately, then awaits the stored promise when a message arrives:

/* Worker thread. */

const importObject = {};
// Instantiate the Wasm module.
// 🚫 If the `Worker` is spun up frequently, the loading
// compiling, and instantiating work will happen every time.
const wasmPromise = WebAssembly.instantiateStreaming(
  fetch('factorial.wasm'),
  importObject,
);

// Listen for incoming messages
self.addEventListener('message', async (e) => {
  const { integer } = e.data;
  const resultObject = await wasmPromise;
  const factorial = resultObject.instance.exports.factorial;
  const result = factorial(integer);
  self.postMessage({ result });
});

Compile once, transfer the module

Performing the load-compile-instantiate sequence inside the worker's message listener repeats expensive work per message. While HTTP caching can make this less costly, there's a cleaner design. The WebAssembly.compileStreaming() result is a WebAssembly.Module, an object that postMessage() can transfer between threads. With this, the main thread loads and compiles the module a single time, then hands it to the worker:

/* Main thread. */

const modulePromise = WebAssembly.compileStreaming(fetch('factorial.wasm'));

let worker = null;

// When the button is clicked, submit the input value
// and the Wasm module to the Web Worker.
button.addEventListener('click', async (e) => {
  e.preventDefault();

  // Create the Web Worker lazily on-demand.
  if (!worker) {
    worker = new Worker('worker.js');

    // Listen for incoming messages and display the result.
    worker.addEventListener('message', (e) => {
      output.textContent = e.result;
    });
  }

  worker.postMessage({
    integer: parseInt(input.value, 10),
    module: await modulePromise,
  });
});

On the worker side, the received WebAssembly.Module only needs instantiation β€” the transfer was not streamed, so the worker calls WebAssembly.instantiate() (not its streaming sibling). If the worker also caches the resulting instance, instantiation happens only once during worker startup.

Inlining the worker

Even with caching, fetching a separate worker script is network work. Inlining the worker as a blob: URL from the main thread's source removes that request. This still requires passing the compiled module across, since a worker's execution context differs from its parent β€” regardless of derivation from the same script:

/* Main thread. */

const modulePromise = WebAssembly.compileStreaming(fetch('factorial.wasm'));

let worker = null;

const blobURL = URL.createObjectURL(
  new Blob(
    [
      `
let instance = null;

self.addEventListener('message', async (e) => {
  // Extract the \`WebAssembly.Module\` from the message.
  const {integer, module} = e.data;
  const importObject = {};
  // Instantiate the Wasm module that came via \`postMessage()\`.
  instance = instance || await WebAssembly.instantiate(module, importObject);
  const factorial = instance.exports.factorial;
  const result = factorial(integer);
  self.postMessage({result});
});
`,
    ],
    { type: 'text/javascript' },
  ),
);

button.addEventListener('click', async (e) => {
  e.preventDefault();

  // Create the Web Worker lazily on-demand.
  if (!worker) {
    worker = new Worker(blobURL);

    // Listen for incoming messages and display the result.
    worker.addEventListener('message', (e) => {
      output.textContent = e.result;
    });
  }

  worker.postMessage({
    integer: parseInt(input.value, 10),
    module: await modulePromise,
  });
});

Worker lifecycle decisions

Timing of worker creation and its lifetime are separate decisions with measurable trade-offs.

In the patterns above, the worker is created lazily β€” only when the user presses the button. An eager alternative creates it during app bootstrap or whenever the app is idle. This is purely a matter of moving the new Worker() call out of the button handler.

The permanent-vs-ad-hoc question is more subtle. A permanent worker can increase the app's baseline memory footprint and complicates result mapping if multiple requests can be in flight. An ad-hoc worker pays per-request start-up cost β€” which can be significant if bootstrapping involves complex state β€” and requires the caller to remember worker.terminate() in every code path, including errors. The right answer depends on the specific use case. The User Timing API can quantify the creation and instantiation overhead instead of guessing.

For the ad-hoc variant, the worker must be torn down after each result:

/* Main thread. */

let worker = null;

const modulePromise = WebAssembly.compileStreaming(fetch('factorial.wasm'));

const blobURL = URL.createObjectURL(
  new Blob(
    [
      `
// Caching the instance means you can switch between
// throw-away and permanent Web Worker freely.
let instance = null;

self.addEventListener('message', async (e) => {
  // Extract the \`WebAssembly.Module\` from the message.
  const {integer, module} = e.data;
  const importObject = {};
  // Instantiate the Wasm module that came via \`postMessage()\`.
  instance = instance || await WebAssembly.instantiate(module, importObject);
  const factorial = instance.exports.factorial;
  const result = factorial(integer);
  self.postMessage({result});
});  
`,
    ],
    { type: 'text/javascript' },
  ),
);

button.addEventListener('click', async (e) => {
  e.preventDefault();
  // Terminate a potentially running Web Worker.
  if (worker) {
    worker.terminate();
  }
  // Create the Web Worker lazily on-demand.
  worker = new Worker(blobURL);
  worker.addEventListener('message', (e) => {
    worker.terminate();
    worker = null;
    output.textContent = e.data.result;
  });
  worker.postMessage({
    integer: parseInt(input.value, 10),
    module: await modulePromise,
  });
});

Measurement in practice

Two runnable demos compare the approaches. One spawns an ad-hoc worker per task; the other uses a permanent worker. Both inline the worker code as a blob: URL. Console logs from User Timing API show wall-clock time from button click to rendered output, and the Network tab reveals the blob: URL request. In the example factorial workload, the permanent worker is roughly 3Γ— faster per task β€” a difference imperceptible at this scale, but one that could matter in a real application with heavier per-request work or tighter latency targets.

Guidelines for a Wasm performance workflow

These patterns combine into a clear set of recommendations for getting the most out of Wasm in an application:

  • Use the streaming forms of Wasm loading and instantiation β€” WebAssembly.compileStreaming() and WebAssembly.instantiateStreaming() β€” whenever the input is a streamed response.
  • Move heavy computation β€” Wasm module. Compile the module once in the main thread (or a dedicated loading worker), then transfer the WebAssembly.Module to the execution worker with postMessage().
  • Evaluate whether infinite-lifetime or per-request workers are the better fit. Factor in instance and state establishment costs, and the handling of concurrent requests.

Adopting these patterns should let you keep Wasm work pressure off the critical UI path while it also runs at its least redundant β€” the combination that keeps web apps responsive.

Review and editorial notes

This performance guide has been reviewed by Andreas Haas, Jakob Kummerow, Deepti Gandluri, Alon Zakai, Francis McCabe, FranΓ§ois Beaufort, and Rachel Andrew.