Running WASM and Heavy Compute Outside the Service Worker
Move CPU-heavy work out of an MV3 service worker: WebAssembly with wasm-unsafe-eval, dedicated workers from an offscreen document with the WORKERS reason, transferring buffers, cancelling, and lifetime limits.
Table of Contents
The extension runs OCR on screenshots, compresses images, hashes large files, or runs a small on-device model. The first version does it in the service worker, and every run freezes all other extension activity for seconds — messages queue up, alarms fire late — and long runs are cut off by the worker’s time limits. Heavy computation needs its own thread. In MV3 the service worker cannot spawn dedicated workers, so the path goes through an offscreen document: it can start a Worker, which runs WebAssembly or plain JavaScript off the main thread, with buffers transferred rather than copied. This guide sets that up. It belongs to offscreen documents and DOM access.
Why the service worker is the wrong place
The extension service worker is single-threaded and event-driven: while a handler is busy computing, no other event for the extension can be processed. A five-second image transform blocks message replies to the popup, delays onAlarm, and makes the extension feel broken. The worker also has hard limits — an event’s work is expected to finish within minutes, and the worker is terminated after a period without extension activity — that do not fit long computations. And service workers cannot construct dedicated Workers in Chrome. An offscreen document is a hidden extension page with a full DOM environment; with the WORKERS reason, it exists to host web workers, which run in parallel threads and can use WebAssembly.
Step-by-step: a compute pipeline
1. Allow WebAssembly in the CSP
1{
2 "permissions": ["offscreen"],
3 "content_security_policy": {
4 "extension_pages": "script-src 'self' 'wasm-unsafe-eval'; object-src 'self'"
5 },
6 "web_accessible_resources": [] // the .wasm stays private; it's loaded by extension pages
7}
Execution context: the manifest. Compiling WebAssembly requires 'wasm-unsafe-eval', the one relaxation the MV3 extension CSP permits; it applies to extension pages and workers they start. The .wasm file must ship in the package — downloading WebAssembly at runtime is remote code. No web-accessible entry is needed because only extension contexts load it.
2. Create the offscreen document on demand
1// sw.js
2async function ensureComputeHost() {
3 const existing = await chrome.runtime.getContexts({ contextTypes: ["OFFSCREEN_DOCUMENT"] });
4 if (existing.length) return;
5 await chrome.offscreen.createDocument({
6 url: "offscreen/compute.html",
7 reasons: ["WORKERS"],
8 justification: "Run image processing in a web worker",
9 });
10}
Execution context: the service worker. Only one offscreen document may exist per extension at a time, so check with runtime.getContexts (Chrome 116+) before creating — a second createDocument throws. If you already use an offscreen document for another reason, add WORKERS to its reasons rather than creating a second one. Creating the document takes tens of milliseconds; keep it alive while work is queued and close it when idle.
3. Start a dedicated worker in the document
1// offscreen/compute.js
2const worker = new Worker(new URL("./compute.worker.js", import.meta.url), { type: "module" });
3const pending = new Map();
4worker.onmessage = ({ data }) => { pending.get(data.id)?.(data); pending.delete(data.id); };
5
6chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
7 if (msg?.target !== "compute") return;
8 const id = crypto.randomUUID();
9 pending.set(id, sendResponse);
10 const bytes = new Uint8Array(msg.bytes);
11 worker.postMessage({ id, op: msg.op, buffer: bytes.buffer }, [bytes.buffer]); // transfer, don't copy
12 return true;
13});
Execution context: the offscreen document, which only has chrome.runtime among extension APIs. It acts as a relay between extension messaging and the web worker. Transferring the ArrayBuffer to the worker moves ownership without copying, which matters for multi-megabyte images. Ids match replies to requests when several jobs run concurrently.
4. Load and run WebAssembly in the web worker
1// offscreen/compute.worker.js
2const wasmReady = WebAssembly.instantiateStreaming(fetch(new URL("./imgproc.wasm", import.meta.url)));
3
4self.onmessage = async ({ data }) => {
5 const { instance } = await wasmReady;
6 const input = new Uint8Array(data.buffer);
7 const out = compressWithWasm(instance, input); // copy into WASM memory, run, copy out
8 self.postMessage({ id: data.id, ok: true, buffer: out.buffer }, [out.buffer]);
9};
Execution context: a dedicated web worker started by the offscreen document, on the extension’s origin and under its CSP. instantiateStreaming compiles while downloading from the package, which is fast and memory-efficient; the module is compiled once per worker and reused for every job. Results are transferred back. Long computations here block nothing else in the extension.
5. Return results to the caller efficiently
1// sw.js
2export async function compressImage(blob) {
3 await ensureComputeHost();
4 const bytes = Array.from(new Uint8Array(await blob.arrayBuffer())); // messaging uses JSON-like cloning
5 const res = await chrome.runtime.sendMessage({ target: "compute", op: "compress", bytes });
6 return new Blob([new Uint8Array(res.buffer ?? res.bytes)], { type: "image/webp" });
7}
Execution context: the service worker. chrome.runtime.sendMessage between extension contexts serialises messages; in current Chrome versions that serialisation is JSON-like, so typed arrays may need converting to plain arrays or base64 for the hop between the worker and the offscreen document, which is a cost for very large data. For large payloads, write the input to IndexedDB in the service worker, pass only a key, and let the offscreen document read it directly — both share the extension’s IndexedDB. See messaging between offscreen documents and the worker.
6. Close the document when idle
1let idleTimer;
2function touchCompute() {
3 clearTimeout(idleTimer);
4 idleTimer = setTimeout(() => chrome.offscreen.closeDocument().catch(() => {}), 60_000);
5}
Execution context: the service worker, calling touchCompute() after each job. An idle offscreen document and its worker hold memory — the WASM module, buffers. Closing after a minute without work releases it; the next job recreates it. For bursty workloads, a slightly longer timeout avoids repeated module compilation.
7. Support cancellation
1// offscreen/compute.js — terminate and recreate the worker to cancel a long job
2chrome.runtime.onMessage.addListener((msg) => {
3 if (msg?.target === "compute-cancel") { worker.terminate(); startWorker(); }
4});
Execution context: the offscreen document. A web worker cannot be interrupted mid-computation from outside except by terminating it. Terminating and starting a fresh worker cancels everything in flight — reply to pending requests with a cancellation error first. Design long jobs to process in chunks if you need finer-grained cancellation.
Common mistakes
- Computing in the service worker. Every other extension event waits.
- Forgetting
'wasm-unsafe-eval'. WebAssembly compilation fails with a CSP error. - Creating a second offscreen document. Only one is allowed; reuse it.
- Copying large buffers through every hop. Use transfer or IndexedDB handoff.
- Never closing the document. Memory stays allocated indefinitely.
Cross-browser variation
- Chrome / Edge: offscreen documents with the
WORKERSreason from Chrome 109+;wasm-unsafe-evalsupported. - Firefox: no offscreen API, but the MV3 background is an event page that can start
Workers directly; use detection to choose the path. - Safari: no offscreen API; start the web worker from an extension page, or run the computation in the containing app via native messaging for very heavy work.
Verification
- Run a heavy job and, during it, send a quick message from the popup: the reply arrives immediately.
- Confirm in
chrome://inspect(or DevTools on the offscreen document) that the computation runs in a worker thread. - Confirm only one offscreen document exists while multiple jobs run.
- Wait past the idle timeout and confirm the document closes.
FAQ
Can I use SharedArrayBuffer for WASM threads?
Only with cross-origin isolation, which extension pages do not have by default. Use multiple workers with transferred buffers instead.
Does the WASM file count against package size limits?
Yes. Large models may need splitting or quantisation to keep the package reasonable.
Can content scripts use this pipeline?
Yes — they message the service worker, which routes to the offscreen host.
Related
- Choosing the right offscreen reason — WORKERS and the other reasons.
- Creating and closing offscreen documents — lifecycle details.
- Keeping service workers alive during long tasks — why not to compute in the worker.
- Offscreen documents and DOM access — the parent topic.