Using WebAssembly in Web Applications

Integrating WebAssembly modules into JavaScript applications for enhanced performance

Introduction

In this lecture, we'll explore practical techniques for integrating WebAssembly into web applications. We'll look at how to load WebAssembly modules, call functions between JavaScript and WebAssembly, share memory, and build real-world applications that leverage WebAssembly's performance benefits.

Key Learning Objectives:

  • Loading and instantiating WebAssembly modules in browsers
  • Calling WebAssembly functions from JavaScript
  • Passing data between JavaScript and WebAssembly
  • Working with WebAssembly memory
  • Implementing practical use cases

Analogy: WebAssembly as a Specialized Coworker

Think of WebAssembly as a specialized coworker who works alongside JavaScript. JavaScript is like the friendly front-office manager who's great at interacting with customers (the DOM, user events), while WebAssembly is like the mathematical wizard in the back office who can crunch numbers at incredible speed. They communicate by passing messages and sharing documents (memory), each doing what they do best to create a more efficient workplace.

Loading WebAssembly Modules

The WebAssembly JavaScript API

Modern browsers provide a standardized API for working with WebAssembly modules. Let's explore the different ways to load WebAssembly:

Method 1: Fetch API + WebAssembly.instantiate

// Fetch the .wasm file as an ArrayBuffer
fetch('module.wasm')
  .then(response => {
    if (!response.ok) {
      throw new Error(`HTTP error! status: ${response.status}`);
    }
    return response.arrayBuffer();
  })
  .then(bytes => {
    // Compile and instantiate the WebAssembly module
    return WebAssembly.instantiate(bytes, importObject);
  })
  .then(result => {
    // Use exports from the WebAssembly instance
    const instance = result.instance;
    const exports = instance.exports;
    
    // Call an exported function
    const result = exports.myFunction(42);
    console.log('Result:', result);
  })
  .catch(error => {
    console.error('Error loading WebAssembly module:', error);
  });

Method 2: Streaming Compilation with instantiateStreaming

// Compile and instantiate while downloading
WebAssembly.instantiateStreaming(fetch('module.wasm'), importObject)
  .then(result => {
    // Use exports from the WebAssembly instance
    const exports = result.instance.exports;
    
    // Call an exported function
    const result = exports.myFunction(42);
    console.log('Result:', result);
  })
  .catch(error => {
    console.error('Error loading WebAssembly module:', error);
  });

Streaming Compilation Advantage

The instantiateStreaming method is more efficient because it starts compiling the WebAssembly code while it's still being downloaded, rather than waiting for the entire download to complete. This is especially beneficial for larger modules.

For this to work, the server must send the .wasm file with the correct MIME type: application/wasm.

Import Object

The importObject parameter allows JavaScript to provide functions and values to the WebAssembly module:

const importObject = {
  env: {
    // Functions that WebAssembly can call
    consoleLog: (value) => {
      console.log('From WebAssembly:', value);
    },
    
    // Values that WebAssembly can use
    memoryBase: 0,
    tableBase: 0,
    
    // Memory and table can also be provided here
    memory: new WebAssembly.Memory({ initial: 10 }),
    table: new WebAssembly.Table({ initial: 0, element: 'anyfunc' })
  }
};

Working with WebAssembly Functions

Calling WebAssembly from JavaScript

Once a WebAssembly module is instantiated, its exported functions can be called directly from JavaScript:

WebAssembly.instantiateStreaming(fetch('math.wasm'))
  .then(result => {
    const exports = result.instance.exports;
    
    // Call exported WebAssembly functions
    console.log('Addition result:', exports.add(5, 3));       // 8
    console.log('Fibonacci result:', exports.fibonacci(10));  // 55
    
    // Functions can be assigned to variables or passed as callbacks
    const multiply = exports.multiply;
    document.getElementById('calculate').addEventListener('click', () => {
      const a = parseInt(document.getElementById('valueA').value);
      const b = parseInt(document.getElementById('valueB').value);
      document.getElementById('result').textContent = multiply(a, b);
    });
  });

Calling JavaScript from WebAssembly

WebAssembly modules can call imported JavaScript functions:

C/C++ with Emscripten

// In C code (processed by Emscripten)
#include <emscripten.h>

// Declare the JavaScript function we want to call
EM_JS(void, alertMessage, (const char* message), {
  alert(UTF8ToString(message));
});

// Export this function to be called from JavaScript
EMSCRIPTEN_KEEPALIVE
void greet(const char* name) {
  // Call back to JavaScript
  alertMessage(name);
}

JavaScript setup

const importObject = {
  env: {
    // No need to provide alertMessage as Emscripten will set it up
    memory: new WebAssembly.Memory({ initial: 10 })
  }
};

WebAssembly.instantiateStreaming(fetch('greetings.wasm'), importObject)
  .then(result => {
    const exports = result.instance.exports;
    
    // This will call our C function, which will call back to JavaScript to show an alert
    exports.greet("WebAssembly");
  });
graph TD A[JavaScript] -->|"exports.greet('WebAssembly')"| B[WebAssembly greet function] B -->|"alertMessage('WebAssembly')"| C[JavaScript alert function] C -->|"alert('WebAssembly')"| D[Browser Alert Dialog]

Data Exchange Between JavaScript and WebAssembly

Primitive Data Types

WebAssembly supports only four primitive types that can be directly passed to and from JavaScript:

When passing i64 values between JavaScript and WebAssembly, you need to use JavaScript's BigInt type, which might not be supported in all browsers.

Complex Data Structures

For complex data structures (strings, arrays, objects), you need to work with WebAssembly's linear memory:

Passing Strings to WebAssembly (C/C++ with Emscripten)

// C/C++ code
#include <emscripten.h>
#include <string.h>

EMSCRIPTEN_KEEPALIVE
int stringLength(const char* str) {
  return strlen(str);
}

JavaScript code

WebAssembly.instantiateStreaming(fetch('strings.wasm'))
  .then(result => {
    const instance = result.instance;
    const exports = instance.exports;
    const memory = exports.memory;
    
    // Function to allocate memory and copy a string into WebAssembly memory
    function passString(str) {
      const encoder = new TextEncoder();
      const bytes = encoder.encode(str + '\0'); // Null-terminated string
      
      // Allocate memory (assuming you have an exported malloc function)
      const ptr = exports.malloc(bytes.length);
      
      // Write the string to memory
      const buffer = new Uint8Array(memory.buffer);
      buffer.set(bytes, ptr);
      
      return ptr;
    }
    
    // Call WebAssembly function with a string
    const message = "Hello, WebAssembly!";
    const ptr = passString(message);
    const length = exports.stringLength(ptr);
    console.log(`String "${message}" has length: ${length}`);
    
    // Don't forget to free the memory when done
    exports.free(ptr);
  });

Getting Strings from WebAssembly

// Function to read a C string from WebAssembly memory
function readString(memory, ptr) {
  const buffer = new Uint8Array(memory.buffer);
  let end = ptr;
  while (buffer[end] !== 0) {
    end++;
  }
  const bytes = buffer.slice(ptr, end);
  const decoder = new TextDecoder();
  return decoder.decode(bytes);
}

// Usage
const str = readString(memory, exports.getGreeting());

Working with WebAssembly Memory

Linear Memory

WebAssembly uses a linear memory model, which is a contiguous, resizable array of bytes that both WebAssembly and JavaScript can access.

WebAssembly Linear Memory Stack Heap Static Data Unused/Growth Space JavaScript Access via Typed Arrays

Creating and Growing Memory

// Create memory with initial 2 pages (128KB)
const memory = new WebAssembly.Memory({ initial: 2, maximum: 10 });

// Pass memory to WebAssembly module
const importObject = {
  env: {
    memory: memory
  }
};

// Access memory as typed arrays from JavaScript
const bytesArray = new Uint8Array(memory.buffer);
const intsArray = new Int32Array(memory.buffer);

// Grow memory by 2 more pages
const oldPages = memory.grow(2); // Returns previous number of pages (2)
console.log(`Memory grown from ${oldPages} to ${memory.buffer.byteLength / 65536} pages`);

// IMPORTANT: After growing memory, you must update your typed arrays
// as the buffer reference may have changed
const newBytesArray = new Uint8Array(memory.buffer);
const newIntsArray = new Int32Array(memory.buffer);

Real-World Example: Image Processing

Image processing is a perfect use case for WebAssembly's memory model. The pixel data from an HTML canvas can be copied to WebAssembly memory, processed efficiently, and then copied back:

// Get pixel data from canvas
const canvas = document.getElementById('myCanvas');
const ctx = canvas.getContext('2d');
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const pixels = imageData.data;

// Copy pixel data to WebAssembly memory
const memory = exports.memory;
const bytesPerPixel = 4; // RGBA
const numPixels = canvas.width * canvas.height;
const memoryBuffer = new Uint8Array(memory.buffer);

// Assume we've allocated memory in WebAssembly
const wasmPixelPtr = exports.allocatePixelBuffer(numPixels * bytesPerPixel);

// Copy pixels to WebAssembly memory
memoryBuffer.set(pixels, wasmPixelPtr);

// Process image in WebAssembly (e.g., apply a blur filter)
exports.applyBlurFilter(wasmPixelPtr, canvas.width, canvas.height);

// Copy processed pixels back to canvas
const processedPixels = memoryBuffer.subarray(
  wasmPixelPtr, 
  wasmPixelPtr + numPixels * bytesPerPixel
);
pixels.set(processedPixels);
ctx.putImageData(imageData, 0, 0);

// Free the memory when done
exports.free(wasmPixelPtr);

Best Practices for WebAssembly Integration

WebAssembly Module Optimization

JavaScript/WebAssembly Interface Design

graph LR A[Inefficient: Multiple Crossings] -->|Slow| B{JS/WASM Boundary} B -->|Slow| A C[Efficient: Batch Processing] -->|One Cross| D{JS/WASM Boundary} D -->|One Cross| C style B fill:#f9a,stroke:#333 style D fill:#af9,stroke:#333

Loading and Caching Strategies

Caching WebAssembly modules with IndexedDB

// Check if we have a cached module
async function loadCachedModule(url) {
  // Open IndexedDB
  const db = await new Promise((resolve, reject) => {
    const request = indexedDB.open('WasmCache', 1);
    request.onupgradeneeded = () => {
      const db = request.result;
      db.createObjectStore('modules');
    };
    request.onsuccess = () => resolve(request.result);
    request.onerror = () => reject(request.error);
  });
  
  // Try to get module from cache
  try {
    const cachedModule = await new Promise((resolve, reject) => {
      const transaction = db.transaction('modules', 'readonly');
      const store = transaction.objectStore('modules');
      const request = store.get(url);
      request.onsuccess = () => resolve(request.result);
      request.onerror = () => reject(request.error);
    });
    
    if (cachedModule) {
      // Instantiate cached module
      return WebAssembly.instantiate(cachedModule, importObject);
    }
  } catch (error) {
    console.warn('Error accessing cache:', error);
  }
  
  // If not cached or error, load and cache
  const fetchResponse = await fetch(url);
  const buffer = await fetchResponse.arrayBuffer();
  
  // Cache the module
  try {
    const transaction = db.transaction('modules', 'readwrite');
    const store = transaction.objectStore('modules');
    store.put(buffer, url);
  } catch (error) {
    console.warn('Error caching module:', error);
  }
  
  // Instantiate fresh module
  return WebAssembly.instantiate(buffer, importObject);
}

Practical WebAssembly Use Cases

Image and Video Processing

WebAssembly excels at processing large amounts of pixel data for operations like:

Audio Processing

Computational Intensive Tasks

Real-World Example: Figma's Vector Graphics Engine

Figma, a popular collaborative design tool, uses WebAssembly to power its vector graphics engine. By compiling their C++ rendering code to WebAssembly, they achieve near-native performance for complex operations like path manipulation, boolean operations on shapes, and rendering thousands of design elements with smooth interactions—all running entirely in the browser.

This approach allows Figma to maintain a single codebase for their core rendering engine while still delivering desktop-quality performance in a web application.

WebAssembly with Popular Frameworks

React Integration

import React, { useState, useEffect } from 'react';

function WasmComponent() {
  const [wasmModule, setWasmModule] = useState(null);
  const [result, setResult] = useState(null);
  const [input, setInput] = useState(10);
  
  // Load WebAssembly module on component mount
  useEffect(() => {
    async function loadWasm() {
      try {
        const result = await WebAssembly.instantiateStreaming(
          fetch('/math.wasm'), 
          {} // Import object
        );
        setWasmModule(result.instance);
      } catch (error) {
        console.error('Failed to load WebAssembly module:', error);
      }
    }
    
    loadWasm();
    
    // Clean up function
    return () => {
      // Any cleanup needed for WebAssembly resources
      setWasmModule(null);
    };
  }, []);
  
  // Calculate result when input or module changes
  useEffect(() => {
    if (wasmModule && input) {
      const fibResult = wasmModule.exports.fibonacci(parseInt(input));
      setResult(fibResult);
    }
  }, [wasmModule, input]);
  
  return (
    <div>
      <h3>WebAssembly Fibonacci Calculator</h3>
      <div>
        <label>
          Number: 
          <input 
            type="number" 
            value={input} 
            onChange={(e) => setInput(e.target.value)} 
          />
        </label>
      </div>
      <div>
        Result: {result !== null ? result : 'Loading...'}
      </div>
    </div>
  );
}

Vue Integration

<template>
  <div>
    <h3>WebAssembly Fibonacci Calculator</h3>
    <div>
      <label>
        Number: 
        <input type="number" v-model.number="input" />
      </label>
    </div>
    <div>
      Result: {{ result !== null ? result : 'Loading...' }}
    </div>
  </div>
</template>

<script>
export default {
  data() {
    return {
      wasmModule: null,
      result: null,
      input: 10
    };
  },
  
  async mounted() {
    try {
      const result = await WebAssembly.instantiateStreaming(
        fetch('/math.wasm'), 
        {} // Import object
      );
      this.wasmModule = result.instance;
      this.calculateResult();
    } catch (error) {
      console.error('Failed to load WebAssembly module:', error);
    }
  },
  
  watch: {
    input() {
      this.calculateResult();
    }
  },
  
  methods: {
    calculateResult() {
      if (this.wasmModule && this.input) {
        this.result = this.wasmModule.exports.fibonacci(this.input);
      }
    }
  }
};
</script>

Angular Integration

// wasm.service.ts
import { Injectable } from '@angular/core';

@Injectable({
  providedIn: 'root'
})
export class WasmService {
  private modulePromise: Promise<WebAssembly.WebAssemblyInstantiatedSource> | null = null;
  
  constructor() { }
  
  loadModule(): Promise<WebAssembly.Instance> {
    if (!this.modulePromise) {
      this.modulePromise = WebAssembly.instantiateStreaming(
        fetch('/math.wasm'), 
        {} // Import object
      );
    }
    
    return this.modulePromise.then(result => result.instance);
  }
}

// wasm.component.ts
import { Component, OnInit } from '@angular/core';
import { WasmService } from './wasm.service';

@Component({
  selector: 'app-wasm',
  template: `
    <div>
      <h3>WebAssembly Fibonacci Calculator</h3>
      <div>
        <label>
          Number: 
          <input type="number" [(ngModel)]="input" (ngModelChange)="calculateResult()" />
        </label>
      </div>
      <div>
        Result: {{ result !== null ? result : 'Loading...' }}
      </div>
    </div>
  `,
})
export class WasmComponent implements OnInit {
  wasmModule: WebAssembly.Instance | null = null;
  result: number | null = null;
  input = 10;
  
  constructor(private wasmService: WasmService) { }
  
  ngOnInit(): void {
    this.wasmService.loadModule().then(instance => {
      this.wasmModule = instance;
      this.calculateResult();
    });
  }
  
  calculateResult(): void {
    if (this.wasmModule && this.input) {
      this.result = this.wasmModule.exports.fibonacci(this.input);
    }
  }
}

Case Study: Building a WebAssembly-powered Image Editor

Architecture Overview

graph TB A[User Interface - React] -->|User Actions| B[JavaScript Controller] B -->|Filter Parameters| C[WebAssembly Image Processing Module] D[Canvas Element] -->|Image Data| C C -->|Processed Image Data| D B -->|Display Updates| A

WebAssembly Image Processing Functions

C++ Image Processing Code (example)

#include <emscripten.h>
#include <cmath>

// Structure to represent RGBA pixel
struct Pixel {
    unsigned char r;
    unsigned char g;
    unsigned char b;
    unsigned char a;
};

// Apply grayscale filter
EMSCRIPTEN_KEEPALIVE
void applyGrayscale(uint8_t* data, int width, int height) {
    Pixel* pixels = reinterpret_cast<Pixel*>(data);
    int numPixels = width * height;
    
    for (int i = 0; i < numPixels; i++) {
        Pixel& pixel = pixels[i];
        unsigned char gray = static_cast<unsigned char>(
            0.299 * pixel.r + 0.587 * pixel.g + 0.114 * pixel.b
        );
        pixel.r = gray;
        pixel.g = gray;
        pixel.b = gray;
        // Alpha remains unchanged
    }
}

// Apply blur filter (simple box blur)
EMSCRIPTEN_KEEPALIVE
void applyBlur(uint8_t* data, int width, int height, int radius) {
    // Create a copy of the original data
    Pixel* pixels = reinterpret_cast<Pixel*>(data);
    int numPixels = width * height;
    Pixel* tempPixels = new Pixel[numPixels];
    
    for (int i = 0; i < numPixels; i++) {
        tempPixels[i] = pixels[i];
    }
    
    // Apply box blur
    for (int y = 0; y < height; y++) {
        for (int x = 0; x < width; x++) {
            int sumR = 0, sumG = 0, sumB = 0;
            int count = 0;
            
            // Sample the surrounding pixels
            for (int ky = -radius; ky <= radius; ky++) {
                for (int kx = -radius; kx <= radius; kx++) {
                    int posX = x + kx;
                    int posY = y + ky;
                    
                    if (posX >= 0 && posX < width && posY >= 0 && posY < height) {
                        Pixel& pixel = tempPixels[posY * width + posX];
                        sumR += pixel.r;
                        sumG += pixel.g;
                        sumB += pixel.b;
                        count++;
                    }
                }
            }
            
            // Calculate average
            Pixel& pixel = pixels[y * width + x];
            pixel.r = static_cast<unsigned char>(sumR / count);
            pixel.g = static_cast<unsigned char>(sumG / count);
            pixel.b = static_cast<unsigned char>(sumB / count);
        }
    }
    
    delete[] tempPixels;
}

JavaScript Integration

class ImageProcessor {
  constructor() {
    this.wasmModule = null;
  }
  
  async initialize() {
    if (!this.wasmModule) {
      const result = await WebAssembly.instantiateStreaming(
        fetch('/image_processor.wasm'),
        {}
      );
      this.wasmModule = result.instance;
    }
    return this;
  }
  
  applyFilter(canvasElement, filterType, options = {}) {
    const ctx = canvasElement.getContext('2d');
    const imageData = ctx.getImageData(
      0, 0, canvasElement.width, canvasElement.height
    );
    const pixels = imageData.data;
    
    // Get the memory we need
    const memory = this.wasmModule.exports.memory;
    const dataPtr = this.wasmModule.exports.malloc(pixels.length);
    
    // Copy canvas data to WebAssembly memory
    const memoryBuffer = new Uint8Array(memory.buffer);
    memoryBuffer.set(pixels, dataPtr);
    
    // Apply the selected filter
    switch(filterType) {
      case 'grayscale':
        this.wasmModule.exports.applyGrayscale(
          dataPtr, 
          canvasElement.width, 
          canvasElement.height
        );
        break;
      case 'blur':
        this.wasmModule.exports.applyBlur(
          dataPtr, 
          canvasElement.width, 
          canvasElement.height, 
          options.radius || 3
        );
        break;
      // Add other filters as needed
    }
    
    // Copy the processed data back to canvas
    const processedPixels = memoryBuffer.subarray(
      dataPtr, 
      dataPtr + pixels.length
    );
    pixels.set(processedPixels);
    ctx.putImageData(imageData, 0, 0);
    
    // Free the memory
    this.wasmModule.exports.free(dataPtr);
  }
}

React Component Integration

import React, { useRef, useEffect, useState } from 'react';
import ImageProcessor from './ImageProcessor';

function ImageEditor() {
  const canvasRef = useRef(null);
  const [imageProcessor, setImageProcessor] = useState(null);
  const [filterType, setFilterType] = useState('none');
  const [blurRadius, setBlurRadius] = useState(3);
  const [isProcessing, setIsProcessing] = useState(false);
  
  // Initialize the image processor
  useEffect(() => {
    async function init() {
      const processor = await new ImageProcessor().initialize();
      setImageProcessor(processor);
    }
    init();
  }, []);
  
  // Load an image to the canvas
  const loadImage = (event) => {
    const file = event.target.files[0];
    if (!file) return;
    
    const reader = new FileReader();
    reader.onload = (e) => {
      const img = new Image();
      img.onload = () => {
        const canvas = canvasRef.current;
        canvas.width = img.width;
        canvas.height = img.height;
        const ctx = canvas.getContext('2d');
        ctx.drawImage(img, 0, 0);
      };
      img.src = e.target.result;
    };
    reader.readAsDataURL(file);
  };
  
  // Apply the selected filter
  const applyFilter = () => {
    if (filterType === 'none' || !imageProcessor) return;
    
    setIsProcessing(true);
    
    // Use setTimeout to allow UI to update before processing starts
    setTimeout(() => {
      imageProcessor.applyFilter(canvasRef.current, filterType, {
        radius: blurRadius
      });
      setIsProcessing(false);
    }, 0);
  };
  
  return (
    <div className="image-editor">
      <h2>WebAssembly Image Editor</h2>
      
      <div className="controls">
        <div>
          <label>
            Load Image:
            <input type="file" accept="image/*" onChange={loadImage} />
          </label>
        </div>
        
        <div>
          <label>
            Filter:
            <select 
              value={filterType} 
              onChange={(e) => setFilterType(e.target.value)}
            >
              <option value="none">None</option>
              <option value="grayscale">Grayscale</option>
              <option value="blur">Blur</option>
            </select>
          </label>
        </div>
        
        {filterType === 'blur' && (
          <div>
            <label>
              Blur Radius:
              <input 
                type="range" 
                min="1" 
                max="10" 
                value={blurRadius} 
                onChange={(e) => setBlurRadius(parseInt(e.target.value))} 
              />
              {blurRadius}
            </label>
          </div>
        )}
        
        <button 
          onClick={applyFilter} 
          disabled={!imageProcessor || filterType === 'none' || isProcessing}
        >
          {isProcessing ? 'Processing...' : 'Apply Filter'}
        </button>
      </div>
      
      <div className="canvas-container">
        <canvas ref={canvasRef}></canvas>
      </div>
    </div>
  );
}

Practice Activities

Activity 1: Simple WebAssembly Integration

Create a simple web page that loads a WebAssembly module with basic math functions. Implement a UI that allows users to call these functions with different inputs.

Activity 2: Memory Sharing

Experiment with sharing memory between JavaScript and WebAssembly. Create an application that modifies an array of numbers in WebAssembly and displays the results in JavaScript.

Activity 3: Benchmark Comparison

Implement the same algorithm (e.g., computing prime numbers) in both JavaScript and WebAssembly. Create a benchmark that compares the performance of both implementations with different input sizes.

Activity 4: Framework Integration

Choose a framework (React, Vue, or Angular) and integrate a WebAssembly module that performs a computation-intensive task. Create a user interface that allows interacting with the WebAssembly functionality.

Summary

In this lecture, we've explored how to use WebAssembly in web applications:

WebAssembly opens up new possibilities for web applications, bringing near-native performance to performance-critical parts of your code while still leveraging JavaScript's strengths for user interfaces and DOM manipulation.

Further Topics to Explore