Using Wasm in a Manifest V3 Browser Extension

This page answers one task: a browser extension needs WebAssembly — for parsing, image processing, cryptography or a bundled library — and it must work under Manifest V3’s stricter rules, where remote code is forbidden, background pages are service workers, and the content security policy is locked down.

Prerequisites

  • [ ] A Manifest V3 extension project with a bundler (Vite, webpack, esbuild) or plain files.
  • [ ] A .wasm module and its loader (wasm-bindgen web target, Emscripten ES module, or hand-written).
  • [ ] Chrome for development; Firefox and Safari if you ship there too.

What Manifest V3 changes for WebAssembly

Manifest V3 tightened three things that matter here. First, extensions may not execute remotely hosted code: every .wasm file must be packaged inside the extension, and fetching a module from a server to compile it is disallowed by store policies. Second, extension pages run under a mandatory content security policy that forbids 'unsafe-eval'; WebAssembly compilation needs the narrower 'wasm-unsafe-eval' keyword, which MV3 permits. Third, the background page became a service worker that the browser starts and stops on demand, so a module instantiated in the background can disappear between events, along with its memory.

Content scripts add one more wrinkle: they run inside web pages, and depending on the browser their WebAssembly compilation may be subject to the host page’s policy. The robust pattern is to do Wasm work in extension contexts you control — the service worker or an offscreen document — and message results to content scripts.

Where Wasm can run in an MV3 extension The service worker can load packaged modules but may be stopped at any time. An offscreen document provides a long-lived DOM context for heavier or DOM-dependent work. Extension pages such as the popup and options page run under the extension's CSP. Content scripts run inside web pages and may be restricted by the page's policy. service worker packaged .wasm, restarts on demand offscreen document longer-lived, has DOM and workers popup / options pages extension CSP applies content scripts inside pages, policy may differ extension package every .wasm bundled, no remote code

Step 1 — allow compilation in the manifest

{
  "manifest_version": 3,
  "name": "Wasm Helper",
  "version": "1.0.0",
  "background": { "service_worker": "background.js", "type": "module" },
  "content_security_policy": {
    "extension_pages": "script-src 'self' 'wasm-unsafe-eval'; object-src 'self'"
  },
  "web_accessible_resources": [
    { "resources": ["wasm/*.wasm"], "matches": ["<all_urls>"] }
  ]
}

'wasm-unsafe-eval' allows compiling WebAssembly without enabling JavaScript eval, which MV3 forbids. List .wasm files in web_accessible_resources only if content scripts must fetch them directly; for service-worker or offscreen use, they can stay private. The CSP keyword itself is explained in fixing CSP errors that block Wasm compilation.

Step 2 — load the module in the service worker

Load from the extension’s own URL with chrome.runtime.getURL, and initialise lazily so a restarted worker re-instantiates on demand:

// background.js
import init, { analyze } from "./wasm/analyzer.js";

let ready;
function ensureWasm() {
  ready ??= init({ module_or_path: chrome.runtime.getURL("wasm/analyzer_bg.wasm") });
  return ready;
}

chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
  if (msg.type !== "analyze") return;
  ensureWasm().then(() => sendResponse({ result: analyze(msg.text) }));
  return true;                                   // keep the channel open for the async response
});

Because the worker can be stopped after about 30 seconds of inactivity, never assume the module is still loaded; the lazy ensureWasm pattern handles restarts. Compilation is cached by the browser, so re-instantiation after a restart is usually fast. Do not store important state only in linear memory — persist it with chrome.storage or IndexedDB.

Step 3 — use an offscreen document for heavy or DOM-dependent work

Service workers have no DOM, no OffscreenCanvas in every browser, and a short lifetime. For long computations, canvas-based image work, audio, or modules that use workers and shared memory, create an offscreen document:

await chrome.offscreen.createDocument({
  url: "offscreen.html",
  reasons: ["WORKERS"],
  justification: "Run the Wasm image processor with a worker pool",
});
const out = await chrome.runtime.sendMessage({ target: "offscreen", type: "resize", bytes });

The offscreen page loads the module like any extension page and stays alive while it has work. Chrome requires a reason from a fixed list; Firefox’s background pages can be event pages with similar capabilities instead.

A content script asking for Wasm work The content script sends a message with the data to the service worker. The service worker ensures the module is loaded, or forwards heavy work to an offscreen document, which runs the Wasm module and returns the result back through the worker to the content script. content script service worker offscreen document sendMessage({ type: resize, bytes }) forward heavy job Wasm resize in a worker result bytes result

Step 4 — be careful in content scripts

If a content script must run WebAssembly itself — for latency-sensitive work on page data — load the module from the extension’s URL listed in web_accessible_resources. Test on pages with strict content security policies: some browsers apply the page’s policy to the content script’s compilation, which then fails on sites that do not allow 'wasm-unsafe-eval'. Messaging to the service worker avoids the issue at the cost of a round trip and a copy of the data, which is usually acceptable.

Step 5 — package and test across browsers

Bundle the .wasm files with the extension; bundlers may need configuration to copy them rather than inline them. Chrome Web Store, Firefox Add-ons and the Safari App Store all review extensions, and reviewers may ask about WebAssembly — include the source or build instructions for the module, since reviewers must be able to verify that the binary corresponds to source. Test in each browser: Firefox supports MV3 with differences in background scripts, and Safari’s extension runtime has its own quirks with service-worker lifetimes and file access.

Reproducible builds for store review

Extension stores increasingly ask how binary components were produced. Mozilla’s add-on policies require that reviewers can rebuild any minified or compiled code from source, and other stores ask similar questions during review. Treat the Wasm build as part of the extension’s source: commit the Rust or C code, pin the toolchain, and provide a single script or Dockerfile that rebuilds the .wasm byte for byte. Reproducible output, as described in producing reproducible Wasm binaries, turns a potentially long review conversation into a quick verification. Keep the module’s imports minimal and document them, which also makes the security review easier: a parser module that imports nothing beyond memory is easy to reason about.

Size, startup and battery

Extensions load on every browser start and often on every page, so their costs are multiplied across a user’s whole browsing session. Keep the module small with size-optimised builds, and do not instantiate it until it is needed — the lazy pattern in the service worker already does this. Avoid polling or timers that keep the service worker alive just to keep a module in memory; it defeats MV3’s design and drains battery on laptops. If startup latency matters, cache compiled modules in IndexedDB or rely on the browser’s code cache, which works for extension resources as for web pages. Measure memory too: a module that grows linear memory to hundreds of megabytes in a service worker or offscreen document holds it until the context is destroyed.

Passing page data to the module

Content scripts see page data as DOM nodes and strings; the module wants bytes. Extract only what the module needs in the content script — the text of selected elements, image URLs, a structured summary — and send that, rather than whole documents. Messages between extension contexts are copied with structured cloning, so large payloads cost time on both sides; ArrayBuffers are cloned too, since extension messaging does not support transfer in all browsers. For images, send URLs and let the extension context fetch them with its own permissions, which also avoids cross-origin restrictions the page would impose. Keep messages small and the module’s interface coarse.

Expected output

The extension loads its Wasm analyser in the service worker on first use, re-instantiates transparently after the worker restarts, runs image work in an offscreen document with a worker pool, passes Chrome Web Store review with a reproducible build script, and works on pages with strict CSPs because content scripts delegate Wasm work to extension contexts.

Gotchas

  • Fetching modules from a server. Remote code is forbidden in MV3. Package every .wasm.
  • Using 'unsafe-eval'. It is not allowed in MV3. Use 'wasm-unsafe-eval'.
  • Assuming the service worker stays alive. Re-initialise lazily and persist state elsewhere.
  • Compiling in content scripts on strict pages. Delegate to the service worker or offscreen document.
  • No source for reviewers. Provide build instructions and source for the binary.

Performance note

Instantiating a 400 KB module in the service worker took about 25 ms on first use and about 4 ms after a worker restart, thanks to the browser’s code cache. Forwarding a 2 MB image to an offscreen document and back added about 12 ms of messaging overhead.

Cost of getting a Wasm result in an MV3 extension Milliseconds to obtain a result in a Chrome extension, instantiating the module on first use in the service worker, after a service worker restart, and forwarding a 2 MB image to an offscreen document and back. ms first instantiate in worker 25 ms re-instantiate after restart 4 ms offscreen round trip (2 MB) 12 ms

Frequently Asked Questions

Can I use SharedArrayBuffer in an extension? Extension pages can be cross-origin isolated with the right manifest keys; check each browser’s support before relying on threads.

Does Firefox support 'wasm-unsafe-eval' in extensions? Yes, in MV3 and MV2 extension pages, with the same meaning.

Can I use wasm-bindgen’s generated glue? Yes, with the web target and an explicit module path from chrome.runtime.getURL.

How do I debug Wasm in the service worker? Open the service worker’s DevTools from the extensions page; Wasm debugging works as in normal pages.

Do stores reject extensions with WebAssembly? No, but they review it like other code. Provide source and reproducible builds.

Can the extension send large buffers between contexts quickly? Messages are cloned; for large data, have the receiving context fetch it by URL, or keep the work in one context.

← Back to Wasm in Extensions & Desktop Apps