Skip to main content
fast frontend

WASM Module Loading Strategies

6 min read Chapter 17 of 33

WASM Module Loading Strategies

The Symptom

The e-commerce platform’s image conversion WASM module (68KB gzipped) loads when the product upload page renders. The module download (68KB, 340ms on 4G) and compilation (75ms) add 415ms to the page load time, even though the user has not yet selected any images. For users who navigate to the upload page just to check their listing status without uploading new images, this is 415ms of wasted time.

The Cause

WebAssembly.instantiate(bytes) requires the complete module binary before compilation begins. The browser downloads the entire .wasm file, then compiles it in a second pass. With WebAssembly.instantiateStreaming(fetch(...)), the browser compiles the module while it downloads, overlapping network and CPU work. But even streaming compilation blocks on the module being requested in the first place.

If the module is requested at page load, it competes with critical resources (JavaScript bundles, CSS, images) for bandwidth and main thread time. Deferring the request until the user initiates an upload eliminates the contention.

The Baseline

Upload page load with eager WASM loading:

PhaseDurationMain Thread Impact
WASM download340ms0ms (network)
WASM compilation75ms75ms
WASM instantiation12ms12ms
Total415ms87ms

With streaming compilation, the download and compilation overlap:

PhaseDurationMain Thread Impact
WASM streaming download+compile340ms~40ms
WASM instantiation12ms12ms
Total352ms52ms

Streaming compilation saves 63ms of wall time and 35ms of main thread time. But the module is still requested at page load.

The Fix

// SLOW: Eager loading at page render
const wasmModule = await WebAssembly.instantiateStreaming(
  fetch("/wasm/image-converter.wasm"),
);

// FAST: Lazy loading on first use with caching
class WasmImageConverter {
  private modulePromise: Promise<WasmImageModule> | null = null;
  private static readonly CACHE_KEY = "wasm-image-converter";
  private static readonly CACHE_VERSION = "v3";

  async getModule(): Promise<WasmImageModule> {
    if (this.modulePromise) return this.modulePromise;
    this.modulePromise = this.loadModule();
    return this.modulePromise;
  }

  private async loadModule(): Promise<WasmImageModule> {
    // Try cached compiled module first
    const cached = await this.loadFromCache();
    if (cached) return cached;

    // Stream-compile from network
    const { instance, module } = await WebAssembly.instantiateStreaming(
      fetch("/wasm/image-converter.wasm"),
    );

    // Cache the compiled module for future page loads
    await this.saveToCache(module);

    return instance.exports as unknown as WasmImageModule;
  }

  private async loadFromCache(): Promise<WasmImageModule | null> {
    try {
      const db = await this.openDB();
      const tx = db.transaction("modules", "readonly");
      const store = tx.objectStore("modules");
      const request = store.get(
        `${WasmImageConverter.CACHE_KEY}-${WasmImageConverter.CACHE_VERSION}`,
      );

      return new Promise((resolve) => {
        request.onsuccess = async () => {
          const compiledModule = request.result?.module as
            | WebAssembly.Module
            | undefined;
          if (!compiledModule) {
            resolve(null);
            return;
          }
          const instance = await WebAssembly.instantiate(compiledModule);
          resolve(instance.exports as unknown as WasmImageModule);
        };
        request.onerror = () => resolve(null);
      });
    } catch {
      return null;
    }
  }

  private async saveToCache(module: WebAssembly.Module): Promise<void> {
    try {
      const db = await this.openDB();
      const tx = db.transaction("modules", "readwrite");
      const store = tx.objectStore("modules");
      store.put({
        key: `${WasmImageConverter.CACHE_KEY}-${WasmImageConverter.CACHE_VERSION}`,
        module,
        timestamp: Date.now(),
      });
    } catch {
      // Cache failure is non-fatal
    }
  }

  private openDB(): Promise<IDBDatabase> {
    return new Promise((resolve, reject) => {
      const request = indexedDB.open("wasm-cache", 1);
      request.onupgradeneeded = () => {
        const db = request.result;
        if (!db.objectStoreNames.contains("modules")) {
          db.createObjectStore("modules", { keyPath: "key" });
        }
      };
      request.onsuccess = () => resolve(request.result);
      request.onerror = () => reject(request.error);
    });
  }
}

const converter = new WasmImageConverter();

// Called only when user clicks "Upload Images"
async function handleUploadClick(files: File[]): Promise<void> {
  const module = await converter.getModule(); // First call loads, subsequent calls are instant
  for (const file of files) {
    await convertImage(module, file);
  }
}

The caching strategy stores the compiled WebAssembly.Module in IndexedDB. A compiled module instantiates in 12ms vs 75ms for compilation from bytes. On the second visit, the user pays only the 12ms instantiation cost, not the 340ms download or 75ms compilation.

The CACHE_VERSION string ensures that a new WASM module deployment invalidates the cache. When the Rust source is updated and a new .wasm binary is deployed, the version string changes and the old cached module is bypassed.

For predictive preloading, if the user navigates to the upload page, preload the WASM module during idle time without blocking page load:

// Preload when user navigates to upload page
function UploadPage(): JSX.Element {
  useEffect(() => {
    // Preload WASM module during idle time
    requestIdleCallback(() => {
      converter.getModule(); // Starts download + compilation in background
    });
  }, []);

  return (
    <div>
      <button onClick={() => handleUploadClick(selectedFiles)}>
        Upload Images
      </button>
    </div>
  );
}

The requestIdleCallback defers the preload until the browser has idle time, preventing the WASM download from competing with the page’s critical resources. When the user clicks “Upload Images,” the module is likely already loaded and cached.

The Proof

ScenarioFirst LoadCached LoadMain Thread Impact
Eager (original)415ms415ms87ms at page load
Lazy + streaming352ms*12ms0ms at page load, 52ms at first use
Lazy + cached352ms*12ms0ms at page load, 12ms at cached use
Lazy + idle preload352ms**12ms0ms at page load, 0ms at use

*Latency is paid at first use, not at page load. **Download happens during idle time, so latency is paid but not felt.

The upload page LCP improved by 87ms from eliminating the WASM compilation during page load. More importantly, TBT dropped by 87ms because the compilation no longer contributes to blocking time during the Lighthouse measurement window.

The Trade-off

IndexedDB caching adds ~50 lines of TypeScript code and introduces a dependency on IndexedDB availability. In private browsing mode, some browsers limit or disable IndexedDB. The fallback (streaming compilation from network) is always available, so the caching layer is a progressive enhancement.

The compiled module stored in IndexedDB occupies browser storage. For the 68KB gzipped WASM module, the compiled module is approximately 200KB in IndexedDB. This is well within the 50MB+ quota that browsers allocate per origin, but should be cleaned up when the cache version changes. Stale cache entries from old versions accumulate if not explicitly deleted.

Idle preloading adds a network request that may not be needed if the user never clicks “Upload.” On mobile connections with metered data, this is a bandwidth cost for a speculative benefit. The 68KB download is small enough that the cost is proportional to the benefit for an e-commerce platform where image upload is a core flow.