WebAssembly Performance Optimization Development

WebAssembly for Compute-Heavy Extension Tasks

E
Extendable Team
· 14 min read

Some extension features require more computational power than JavaScript alone can efficiently provide. WebAssembly (Wasm) enables near-native performance for tasks like image processing, cryptography, data compression, and complex algorithms—all within the browser security model.

When to Use WebAssembly

WebAssembly excels at:

  • CPU-intensive calculations: Hashing, compression, encoding
  • Image/video processing: Filters, resizing, format conversion
  • Cryptography: Encryption, key derivation, signing
  • Data parsing: Binary formats, large datasets
  • Porting existing code: C/C++/Rust libraries to web
When NOT to use Wasm:
  • DOM manipulation (Wasm can't access DOM directly)
  • Simple operations (JS overhead for Wasm calls)
  • Highly async workloads (Wasm is synchronous)
  • Small code size is critical (Wasm adds bundle size)

Setting Up WebAssembly

Rust has excellent WebAssembly support through wasm-pack:

# Install wasm-pack
curl https://rustwasm.github.io/wasm-pack/installer/init.sh -sSf | sh

# Create new Wasm project
cargo new --lib extension-wasm
cd extension-wasm
# Cargo.toml
[package]
name = "extension-wasm"
version = "0.1.0"
edition = "2021"

[lib]
crate-type = ["cdylib", "rlib"]

[dependencies]
wasm-bindgen = "0.2"
js-sys = "0.3"
web-sys = { version = "0.3", features = ["console"] }

[profile.release]
lto = true
opt-level = "z"  # Optimize for size
// src/lib.rs
use wasm_bindgen::prelude::*;

#[wasm_bindgen]
pub fn hash_data(data: &[u8]) -> String {
    use std::collections::hash_map::DefaultHasher;
    use std::hash::{Hash, Hasher};

    let mut hasher = DefaultHasher::new();
    data.hash(&mut hasher);
    format!("{:x}", hasher.finish())
}

#[wasm_bindgen]
pub fn compress_string(input: &str) -> Vec<u8> {
    // Simple RLE compression example
    let bytes = input.as_bytes();
    let mut result = Vec::new();

    let mut i = 0;
    while i < bytes.len() {
        let byte = bytes[i];
        let mut count = 1u8;

        while i + (count as usize) < bytes.len()
            && bytes[i + (count as usize)] == byte
            && count < 255
        {
            count += 1;
        }

        result.push(count);
        result.push(byte);
        i += count as usize;
    }

    result
}

#[wasm_bindgen]
pub fn decompress_to_string(compressed: &[u8]) -> String {
    let mut result = Vec::new();

    let mut i = 0;
    while i < compressed.len() {
        let count = compressed[i] as usize;
        let byte = compressed[i + 1];

        for _ in 0..count {
            result.push(byte);
        }
        i += 2;
    }

    String::from_utf8(result).unwrap_or_default()
}

Build for browser:

wasm-pack build --target web --release

C/C++ with Emscripten

For existing C/C++ code:

// image_process.c
#include <emscripten.h>
#include <stdint.h>

EMSCRIPTEN_KEEPALIVE
void grayscale(uint8_t* pixels, int width, int height) {
    for (int i = 0; i < width * height; i++) {
        int offset = i * 4;  // RGBA
        uint8_t r = pixels[offset];
        uint8_t g = pixels[offset + 1];
        uint8_t b = pixels[offset + 2];

        // Luminance formula
        uint8_t gray = (uint8_t)(0.299 * r + 0.587 * g + 0.114 * b);

        pixels[offset] = gray;
        pixels[offset + 1] = gray;
        pixels[offset + 2] = gray;
        // Alpha unchanged
    }
}

EMSCRIPTEN_KEEPALIVE
void blur(uint8_t* pixels, int width, int height, int radius) {
    // Box blur implementation
    uint8_t* temp = malloc(width * height * 4);

    for (int y = 0; y < height; y++) {
        for (int x = 0; x < width; x++) {
            int rSum = 0, gSum = 0, bSum = 0, count = 0;

            for (int dy = -radius; dy <= radius; dy++) {
                for (int dx = -radius; dx <= radius; dx++) {
                    int nx = x + dx;
                    int ny = y + dy;

                    if (nx >= 0 && nx < width && ny >= 0 && ny < height) {
                        int offset = (ny * width + nx) * 4;
                        rSum += pixels[offset];
                        gSum += pixels[offset + 1];
                        bSum += pixels[offset + 2];
                        count++;
                    }
                }
            }

            int offset = (y * width + x) * 4;
            temp[offset] = rSum / count;
            temp[offset + 1] = gSum / count;
            temp[offset + 2] = bSum / count;
            temp[offset + 3] = pixels[offset + 3];  // Keep alpha
        }
    }

    memcpy(pixels, temp, width * height * 4);
    free(temp);
}

Compile with Emscripten:

emcc image_process.c -O3 \
    -s WASM=1 \
    -s EXPORTED_FUNCTIONS='["_grayscale", "_blur", "_malloc", "_free"]' \
    -s EXPORTED_RUNTIME_METHODS='["ccall", "cwrap"]' \
    -o image_process.js

Loading WebAssembly in Extensions

From Rust/wasm-pack

// wasm-loader.js
let wasmModule = null;

async function loadWasm() {
  if (wasmModule) return wasmModule;

  // Load from extension bundle
  const wasmUrl = chrome.runtime.getURL('wasm/extension_wasm_bg.wasm');
  const response = await fetch(wasmUrl);
  const bytes = await response.arrayBuffer();

  // Import the generated JS bindings
  const { default: init, hash_data, compress_string, decompress_to_string } =
    await import(chrome.runtime.getURL('wasm/extension_wasm.js'));

  // Initialize with the wasm bytes
  await init(bytes);

  wasmModule = { hash_data, compress_string, decompress_to_string };
  return wasmModule;
}

// Usage
async function processData(data) {
  const wasm = await loadWasm();
  const hash = wasm.hash_data(new Uint8Array(data));
  return hash;
}

From Emscripten

// emscripten-loader.js
let Module = null;

async function loadEmscriptenModule() {
  if (Module) return Module;

  // Load the generated JS file
  const script = document.createElement('script');
  script.src = chrome.runtime.getURL('wasm/image_process.js');

  await new Promise((resolve, reject) => {
    script.onload = resolve;
    script.onerror = reject;
    document.head.appendChild(script);
  });

  // Wait for Module to be ready
  Module = await new Promise(resolve => {
    window.Module = {
      onRuntimeInitialized: function() {
        resolve(window.Module);
      },
      locateFile: function(file) {
        return chrome.runtime.getURL('wasm/' + file);
      }
    };
  });

  return Module;
}

// Wrapped functions
async function grayscale(imageData) {
  const module = await loadEmscriptenModule();
  const { width, height, data } = imageData;

  // Allocate memory in Wasm heap
  const ptr = module._malloc(data.length);
  module.HEAPU8.set(data, ptr);

  // Call Wasm function
  module._grayscale(ptr, width, height);

  // Read result back
  const result = new Uint8ClampedArray(module.HEAPU8.subarray(ptr, ptr + data.length));

  // Free memory
  module._free(ptr);

  return new ImageData(result, width, height);
}

Real-World Use Cases

Image Processing Pipeline

// image-processor.js
class WasmImageProcessor {
  constructor() {
    this.module = null;
    this.ready = this.initialize();
  }

  async initialize() {
    this.module = await loadWasmModule();
  }

  async processImage(canvas, operations) {
    await this.ready;

    const ctx = canvas.getContext('2d');
    const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);

    // Apply operations in sequence
    for (const op of operations) {
      switch (op.type) {
        case 'grayscale':
          await this.grayscale(imageData);
          break;
        case 'blur':
          await this.blur(imageData, op.radius);
          break;
        case 'sharpen':
          await this.sharpen(imageData, op.amount);
          break;
        case 'resize':
          imageData = await this.resize(imageData, op.width, op.height);
          break;
      }
    }

    ctx.putImageData(imageData, 0, 0);
    return canvas;
  }

  async grayscale(imageData) {
    const ptr = this.allocateImageData(imageData);
    this.module._grayscale(ptr, imageData.width, imageData.height);
    this.copyBackImageData(imageData, ptr);
    this.module._free(ptr);
  }

  async blur(imageData, radius) {
    const ptr = this.allocateImageData(imageData);
    this.module._blur(ptr, imageData.width, imageData.height, radius);
    this.copyBackImageData(imageData, ptr);
    this.module._free(ptr);
  }

  allocateImageData(imageData) {
    const ptr = this.module._malloc(imageData.data.length);
    this.module.HEAPU8.set(imageData.data, ptr);
    return ptr;
  }

  copyBackImageData(imageData, ptr) {
    const result = this.module.HEAPU8.subarray(ptr, ptr + imageData.data.length);
    imageData.data.set(result);
  }
}

// Usage in content script
const processor = new WasmImageProcessor();

async function processPageImages() {
  const images = document.querySelectorAll('img');

  for (const img of images) {
    const canvas = document.createElement('canvas');
    canvas.width = img.naturalWidth;
    canvas.height = img.naturalHeight;

    const ctx = canvas.getContext('2d');
    ctx.drawImage(img, 0, 0);

    await processor.processImage(canvas, [
      { type: 'sharpen', amount: 0.5 }
    ]);

    img.src = canvas.toDataURL();
  }
}
Memory Management: WebAssembly uses linear memory. Always free allocated memory to prevent leaks. Consider using a memory pool for frequently allocated/freed buffers.

Cryptography Module

// src/crypto.rs
use wasm_bindgen::prelude::*;
use sha2::{Sha256, Digest};
use aes_gcm::{Aes256Gcm, Key, Nonce};
use aes_gcm::aead::{Aead, KeyInit};

#[wasm_bindgen]
pub fn sha256(data: &[u8]) -> Vec<u8> {
    let mut hasher = Sha256::new();
    hasher.update(data);
    hasher.finalize().to_vec()
}

#[wasm_bindgen]
pub fn encrypt_aes_gcm(key: &[u8], nonce: &[u8], plaintext: &[u8]) -> Result<Vec<u8>, JsValue> {
    if key.len() != 32 {
        return Err(JsValue::from_str("Key must be 32 bytes"));
    }
    if nonce.len() != 12 {
        return Err(JsValue::from_str("Nonce must be 12 bytes"));
    }

    let cipher = Aes256Gcm::new(Key::<Aes256Gcm>::from_slice(key));
    let nonce = Nonce::from_slice(nonce);

    cipher.encrypt(nonce, plaintext)
        .map_err(|e| JsValue::from_str(&format!("Encryption failed: {}", e)))
}

#[wasm_bindgen]
pub fn decrypt_aes_gcm(key: &[u8], nonce: &[u8], ciphertext: &[u8]) -> Result<Vec<u8>, JsValue> {
    if key.len() != 32 {
        return Err(JsValue::from_str("Key must be 32 bytes"));
    }
    if nonce.len() != 12 {
        return Err(JsValue::from_str("Nonce must be 12 bytes"));
    }

    let cipher = Aes256Gcm::new(Key::<Aes256Gcm>::from_slice(key));
    let nonce = Nonce::from_slice(nonce);

    cipher.decrypt(nonce, ciphertext)
        .map_err(|e| JsValue::from_str(&format!("Decryption failed: {}", e)))
}

Data Compression for Storage

// Use Wasm-based compression to reduce storage usage
class WasmStorage {
  constructor() {
    this.wasm = null;
    this.ready = this.init();
  }

  async init() {
    this.wasm = await loadWasm();
  }

  async set(key, value) {
    await this.ready;

    const json = JSON.stringify(value);
    const compressed = this.wasm.compress_string(json);

    // Store compressed data
    await chrome.storage.local.set({
      [key]: {
        compressed: true,
        data: Array.from(compressed)
      }
    });

    // Log compression ratio
    const ratio = (compressed.length / json.length * 100).toFixed(1);
    console.log(`Compressed ${key}: ${json.length} -> ${compressed.length} (${ratio}%)`);
  }

  async get(key) {
    await this.ready;

    const result = await chrome.storage.local.get(key);
    const stored = result[key];

    if (!stored) return null;

    if (stored.compressed) {
      const decompressed = this.wasm.decompress_to_string(new Uint8Array(stored.data));
      return JSON.parse(decompressed);
    }

    return stored;
  }
}

Performance Optimization

Memory Pooling

// Avoid repeated allocations
class WasmMemoryPool {
  constructor(module, blockSize = 1024 * 1024) {
    this.module = module;
    this.blockSize = blockSize;
    this.pools = new Map();
  }

  acquire(size) {
    // Round up to next power of 2 for pooling
    const poolSize = Math.pow(2, Math.ceil(Math.log2(size)));

    if (!this.pools.has(poolSize)) {
      this.pools.set(poolSize, []);
    }

    const pool = this.pools.get(poolSize);

    if (pool.length > 0) {
      return pool.pop();
    }

    return this.module._malloc(poolSize);
  }

  release(ptr, size) {
    const poolSize = Math.pow(2, Math.ceil(Math.log2(size)));
    const pool = this.pools.get(poolSize);

    if (pool && pool.length < 10) {  // Max 10 per size
      pool.push(ptr);
    } else {
      this.module._free(ptr);
    }
  }

  clear() {
    for (const [size, pool] of this.pools) {
      for (const ptr of pool) {
        this.module._free(ptr);
      }
    }
    this.pools.clear();
  }
}

Worker Thread Integration

// wasm-worker.js
let wasm = null;

self.onmessage = async (e) => {
  const { type, id, data } = e.data;

  try {
    if (!wasm) {
      wasm = await loadWasm();
    }

    let result;
    switch (type) {
      case 'hash':
        result = wasm.hash_data(new Uint8Array(data));
        break;
      case 'compress':
        result = Array.from(wasm.compress_string(data));
        break;
      case 'process_image':
        result = await processImageInWorker(data);
        break;
    }

    self.postMessage({ id, result });
  } catch (error) {
    self.postMessage({ id, error: error.message });
  }
};

// main.js - Worker management
class WasmWorkerPool {
  constructor(workerCount = navigator.hardwareConcurrency || 4) {
    this.workers = [];
    this.queue = [];
    this.pending = new Map();
    this.nextId = 0;

    for (let i = 0; i < workerCount; i++) {
      const worker = new Worker(chrome.runtime.getURL('wasm-worker.js'));
      worker.onmessage = this.handleMessage.bind(this);
      this.workers.push({ worker, busy: false });
    }
  }

  async execute(type, data) {
    const id = this.nextId++;

    return new Promise((resolve, reject) => {
      this.pending.set(id, { resolve, reject });
      this.queue.push({ id, type, data });
      this.processQueue();
    });
  }

  processQueue() {
    const available = this.workers.find(w => !w.busy);
    if (!available || this.queue.length === 0) return;

    const task = this.queue.shift();
    available.busy = true;
    available.currentId = task.id;
    available.worker.postMessage(task);
  }

  handleMessage(e) {
    const { id, result, error } = e.data;
    const handler = this.pending.get(id);

    if (handler) {
      if (error) {
        handler.reject(new Error(error));
      } else {
        handler.resolve(result);
      }
      this.pending.delete(id);
    }

    const worker = this.workers.find(w => w.currentId === id);
    if (worker) {
      worker.busy = false;
      worker.currentId = null;
    }

    this.processQueue();
  }
}

Summary

WebAssembly unlocks performance-critical functionality in browser extensions while maintaining security. Use it for CPU-bound tasks, leverage Rust or C++ ecosystems, and combine with Web Workers for parallel processing.

Key implementation points:

  • Choose Rust for new Wasm code (best tooling)
  • Use Emscripten to port existing C/C++ code
  • Manage Wasm memory carefully (pools, explicit frees)
  • Offload to Web Workers for heavy computation
  • Profile before and after to validate performance gains
  • Consider bundle size impact (tree-shake unused code)