../

Web Workers

Run TypeScript off the main thread: dedicated and shared workers, messaging with structured clone and transfer, typed protocols, shared memory with Atomics, OffscreenCanvas, Comlink, pools, and bundler setup (Vite 8, Next.js 16, Bun). Service workers have their own sheet: Service workers.

Worker types

KindCreated withShared byLives untilBaseline
Dedicated workernew Worker(url, opts)the page that made itterminate(), close(), or page unloadwidely available
Module workernew Worker(url, { type: "module" })samesamewidely available (2023)
Shared workernew SharedWorker(url, opts)every same-origin page or tab that names itlast connected page closes2026 (newly available; Chrome Android 148)
Service workernavigator.serviceWorker.register(url)all pages in its scopebrowser decides; event-drivenwidely available
WorkletsCSS.paintWorklet, audioContext.audioWorkletthe renderer / audio threadengine-managedvaries
In a workerNot in a worker
fetch, WebSocket, EventSource, BroadcastChanneldocument, DOM nodes, window
IndexedDB, Cache API, OPFS (plus sync access handles)localStorage, sessionStorage
crypto.subtle, TextEncoder, streams, WebAssemblyalert, confirm
setTimeout, setInterval, requestAnimationFrame (dedicated)layout, getComputedStyle
OffscreenCanvas, createImageBitmap, WebGPUHTMLCanvasElement
importScripts() (classic only), import (module only)import in classic workers
navigator.hardwareConcurrency, navigator.storage, locationnavigator.clipboard

Creating a worker

main.ts
const worker = new Worker(
  new URL("./math.worker.ts", import.meta.url),
  { type: "module", name: "math" },
);
 
worker.onmessage = (e: MessageEvent<number>) => {
  console.log("result", e.data);
};
worker.postMessage([1, 2, 3]);
math.worker.ts
self.onmessage = (e: MessageEvent<number[]>) => {
  const sum = e.data.reduce((a, b) => a + b, 0);
  self.postMessage(sum);
};
OptionValuesNotes
type"classic" (default), "module"module workers get import/export and strict mode
namestringshows in DevTools; self.name inside
credentialsomit, same-origin (default), includemodule workers only

new URL("./x.ts", import.meta.url) is the pattern bundlers (Vite, webpack 5, Turbopack, Parcel, esbuild plugins) detect: they bundle the worker as its own entry and rewrite the URL. Worker scripts must be same-origin; for a CDN, load a same-origin stub or a blob: URL that imports the real module.

Messaging & structured clone

postMessage(value) copies value with the structured clone algorithm; the other side gets a MessageEvent whose data is the copy. Delivery is async and in order.

Clones fineThrows DataCloneErrorSilently lost
primitives (not symbol), plain objects, arraysfunctions, class methodsprototype: class instances arrive as plain objects
Date, RegExp, Map, Set, BigIntDOM nodesgetters/setters, property descriptors
ArrayBuffer, typed arrays, DataViewsymbol valuesclass private fields
Blob, File, ImageData, ImageBitmap, CryptoKeyWeakMap, WeakRef, promisesRegExp.lastIndex
Error types (TypeError, RangeError, …), DOMExceptionproxiescustom Error subclass names (become Error)

A value that clones on send but fails to deserialize fires messageerror instead of message. Cloning a large object graph costs time on both threads (serialize, then deserialize); for large binary data, transfer it instead.

declare const worker: Worker;
 
// also works outside workers: deep copy with the same rules
const copy = structuredClone({
  at: new Date(),
  tags: new Set(["a"]),
});
 
worker.postMessage(copy);                   // copied
worker.postMessage(copy, { transfer: [] }); // options form

Transferables

Transferring moves ownership instead of copying: near-zero cost, and the sender's object is detached (an ArrayBuffer gets byteLength 0; using it throws).

TransferableTypical use
ArrayBufferaudio samples, file bytes, pixels, WASM data
MessagePorta private channel between two contexts
ImageBitmap, OffscreenCanvas, VideoFrame, AudioDatagraphics and media pipelines
ReadableStream, WritableStream, TransformStreamstream a response into a worker; Baseline 2026 (newly available)
MediaStreamTrack, RTCDataChannel, MIDIAccess, MediaSourceHandlemedia, WebRTC
declare const worker: Worker;
 
const pixels = new Uint8ClampedArray(1920 * 1080 * 4);
worker.postMessage({ pixels }, [pixels.buffer]);
pixels.byteLength; // 0: detached
 
// ES2024: move a buffer, optionally resizing it
const a = new ArrayBuffer(8);
const b = a.transfer(16); // a is now detached

Typed arrays themselves aren't transferable; list their .buffer. A typed array that views part of a shared buffer transfers the whole buffer.

A typed message protocol

MessageEvent.data is any. Pin both directions with discriminated unions in a shared file that imports nothing from the DOM or the worker.

protocol.ts
export type Req =
  | { type: "sum"; numbers: number[] }
  | { type: "parseCsv"; csv: string };
 
// result type for each request type
export type ResultOf = {
  sum: number;
  parseCsv: string[][];
};
 
export type Call = { id: number; req: Req };
 
export type Reply =
  | { id: number; ok: true; result: ResultOf[Req["type"]] }
  | { id: number; ok: false; error: string };
rpc.worker.ts
import type * as P from "./protocol.ts";
 
function handle(req: P.Req): P.ResultOf[P.Req["type"]] {
  switch (req.type) {
    case "sum":
      return req.numbers.reduce((a, b) => a + b, 0);
    case "parseCsv":
      return req.csv
        .trim()
        .split("\n")
        .map((line) => line.split(","));
  }
}
 
self.onmessage = (e: MessageEvent<P.Call>) => {
  const { id, req } = e.data;
  let reply: P.Reply;
  try {
    reply = { id, ok: true, result: handle(req) };
  } catch (err) {
    reply = { id, ok: false, error: String(err) };
  }
  self.postMessage(reply);
};

The switch is exhaustive: add a Req member and handle stops compiling until you handle it. The page side, with request ids and promises, is the first recipe below.

TypeScript setup

lib.dom and lib.webworker declare the same globals differently, so one program can't include both. Give worker files their own tsconfig.

split lib settings
src/workers/tsconfig.json  # lib: es2024, webworkerrpc.worker.tsprotocol.ts        # types only, no globalsmain.tstsconfig.json          # lib: es2024, dom; excludes workers/
src/workers/tsconfig.json
{
  "extends": "../../tsconfig.json",
  "compilerOptions": {
    "lib": ["es2024", "webworker"],
    "types": []
  },
  "include": ["./**/*.ts", "../protocol.ts"]
}
Worker kindGlobal self typeFix in the file
DedicatedWorkerGlobalScope & typeof globalThis; postMessage, onmessage are globalsnothing
Sharedsame, but no onconnectdeclare const self: SharedWorkerGlobalScope;
Servicesame, but no clients, registrationdeclare const self: ServiceWorkerGlobalScope;

The declare const self trick needs the file to be a module (any import/export). With skipLibCheck: true, a single /// <reference lib="webworker" /> in a DOM project hides the conflicts, but DOM globals like document then type-check inside the worker and fail at runtime.

Errors & termination

Where it goes wrongWhat the page sees
script 404, bad MIME type, syntax error at loaderror event (a plain Event) on the Worker
uncaught exception in the workerErrorEvent on the Worker (message, filename, lineno); worker keeps running
unhandled promise rejection in the workernothing: unhandledrejection fires only inside the worker
message can't be deserializedmessageerror
worker.terminate()worker stops at once; no cleanup, pending replies never arrive
self.close() insideworker finishes the current task, then stops
declare const worker: Worker;
 
worker.addEventListener("error", (e) => {
  if (e instanceof ErrorEvent) {
    console.error(`${e.filename}:${e.lineno} ${e.message}`);
  } else {
    console.error("worker failed to load");
  }
  e.preventDefault(); // suppress the console duplicate
});
worker.addEventListener("messageerror", () => {
  console.error("unclonable message");
});

Terminate workers you no longer need: an idle worker still holds its own heap (several MB). In React, create the worker in useEffect and terminate() it in the cleanup.

Shared workers

One instance per origin and URL (plus name), shared by all tabs: good for one WebSocket or one database connection per user instead of per tab. Baseline 2026 (newly available).

main.ts
const shared = new SharedWorker(
  new URL("./hub.shared-worker.ts", import.meta.url),
  { type: "module", name: "hub" },
);
shared.port.onmessage = (e: MessageEvent<number>) => {
  console.log("tabs connected:", e.data);
};
shared.port.postMessage("hello");
hub.shared-worker.ts
declare const self: SharedWorkerGlobalScope;
 
const ports = new Set<MessagePort>();
 
self.onconnect = (e: MessageEvent) => {
  const port = e.ports[0];
  if (!port) return;
  ports.add(port);
  port.onmessage = () => {
    for (const p of ports) p.postMessage(ports.size);
  };
};
 
export {};

Setting port.onmessage starts the port; with addEventListener("message", …) call port.start(). There is no disconnect event: have tabs send "bye" on pagehide, or prune ports that stop answering. Debug at chrome://inspect/#workers.

SharedArrayBuffer & Atomics

Real shared memory between threads. It needs a cross-origin isolated page, which the server enables with two headers on the document:

Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
PieceDetail
crossOriginIsolatedtrue when both headers are in effect; check before using SharedArrayBuffer
COEP require-corpevery cross-origin subresource needs CORS or Cross-Origin-Resource-Policy
COEP credentiallessalternative: cross-origin no-CORS loads go without cookies; check support
COOP same-originbreaks window.opener links with cross-origin popups (OAuth popups!)
BaselineSharedArrayBuffer widely available (2021); Atomics.waitAsync 2025
declare const worker: Worker;
 
if (!crossOriginIsolated) {
  throw new Error("SharedArrayBuffer needs COOP + COEP");
}
const sab = new SharedArrayBuffer(4); // room for one Int32
const flag = new Int32Array(sab);
worker.postMessage(sab); // shared, neither copied nor moved
 
Atomics.store(flag, 0, 1);
Atomics.notify(flag, 0); // wake a worker blocked in wait()
AtomicsUse
load, store, add, sub, and, or, xor, exchangerace-free reads and writes
compareExchange(ta, i, expected, value)lock-free updates, spinlocks
wait(ta, i, value, timeout?)block until notified; throws on the main thread
waitAsync(ta, i, value, timeout?)promise version, allowed on the main thread
notify(ta, i, count?)wake waiters

Only integer typed arrays (Int32Array, BigInt64Array) work with wait/notify.

OffscreenCanvas

Hand a canvas to a worker and render there, so heavy drawing never blocks input. Widely available (2023).

main.ts
declare const worker: Worker;
 
const canvas = document.querySelector("canvas")!;
const offscreen = canvas.transferControlToOffscreen();
worker.postMessage({ canvas: offscreen }, [offscreen]);
render.worker.ts
type Init = { canvas: OffscreenCanvas };
 
self.onmessage = (e: MessageEvent<Init>) => {
  const { canvas } = e.data;
  const ctx = canvas.getContext("2d");
  if (!ctx) return;
  const frame = (t: number) => {
    ctx.clearRect(0, 0, canvas.width, canvas.height);
    ctx.fillRect(100 + Math.sin(t / 300) * 80, 60, 40, 40);
    requestAnimationFrame(frame);
  };
  requestAnimationFrame(frame);
};

After the transfer the page can't draw on or resize that canvas's bitmap; send size changes as messages. Drawing APIs: Canvas 2D, WebGL, WebGPU.

Comlink (opens in a new tab) (4.x, ~1.1 kB) turns a worker into an async object: expose(obj) on one side, wrap<T>(worker) on the other, and every call returns a promise. bun add comlink.

APIUse
expose(value, endpoint?)serve an object or function; endpoint defaults to self
wrap<T>(endpoint)returns Remote<T>: same shape, every member async
transfer(value, transferables)mark an argument or return value for transfer
proxy(value)pass a callback or object by reference (progress callbacks)
proxy[releaseProxy]()free the remote side; call when done
endpoint.port of a SharedWorkerwrap it; expose inside onconnect

Remote<T> is best-effort: overloads and generics sometimes need as unknown as Remote<X>. See the Comlink recipe below.

Worker pools

QuestionAnswer
How many workers?navigator.hardwareConcurrency - 1 (leave the main thread a core), capped at ~4–8
Startup costtens of ms and a few MB each: create once, reuse
Task sizeat least a few ms of work; tiny tasks lose to messaging overhead
Cancellationterminate() the busy worker and spawn a replacement
Librariesworkerpool, threads.js, poolifier-web-worker; or the recipe below

Bundlers & frameworks

ToolHow to load a worker
Vite 8new Worker(new URL("./w.ts", import.meta.url), { type: "module" }); or import W from "./w.ts?worker" then new W(); ?sharedworker, ?worker&inline, ?worker&url
Next.js 16 (Turbopack and webpack)new URL(..., import.meta.url) pattern inside a Client Component effect; turbopackWorkerAssetPrefix overrides the worker asset prefix
webpack 5same new URL pattern; worker-loader is obsolete
esbuild / Bun.buildbuild the worker as a separate entry point and pass its URL

Vite and Turbopack only detect the pattern when new URL(...) is written inline in the constructor and the options are string literals.

use-worker.tsx
"use client";
import { useEffect, useRef } from "react";
 
export function useWorker(): React.RefObject<Worker | null> {
  const ref = useRef<Worker | null>(null);
  useEffect(() => {
    const w = new Worker(
      new URL("./rpc.worker.ts", import.meta.url),
      { type: "module" },
    );
    ref.current = w;
    return () => w.terminate(); // Strict Mode runs this too
  }, []);
  return ref;
}

Workers can't be created during server rendering; keep them in effects or event handlers. For COOP/COEP in Next.js, set them in headers() in next.config.ts; in Vite, server.headers.

Bun & Node workers

Bun implements the same Web Worker API on the server, with TypeScript and ESM out of the box. See Bun.

bun-main.ts
const worker = new Worker(
  new URL("./hash.worker.ts", import.meta.url).href,
  { smol: true }, // smaller heap
);
worker.addEventListener("open", () => console.log("ready"));
worker.onmessage = (e: MessageEvent<string>) => {
  console.log(e.data);
  worker.terminate();
};
worker.postMessage("hello");
worker.unref(); // don't keep the process alive for it
BrowserBunNode worker_threads
APIWorker, self.onmessagesame Web API (plus node:worker_threads)new Worker(file), parentPort.on("message")
TypeScriptvia bundlerruns .ts directlyNode 23.6+ strips types; else a loader
type: "module"required for ESMnot neededby file extension / package.json
Relative path resolves fromthe page URLthe project root; use import.meta.urlthe cwd; use new URL(..., import.meta.url)
ExtrasSharedWorker, OffscreenCanvassmol, preload, ref/unref, open and close eventsresourceLimits, workerData
StatusstableBun marks Worker experimental (mainly termination)stable

In a Bun worker file, declare var self: Worker; gives self.postMessage a type without the webworker lib.

Pitfalls

TrapFix
import fails in the workerpass { type: "module" }
Worker URL 404s after builduse the new URL("./x.ts", import.meta.url) pattern inline
Class instance arrives without methodssend plain data; rebuild the instance on the other side
Main thread janks while postinga huge object is being cloned: transfer an ArrayBuffer or send less
Buffer is empty after postMessageit was transferred; copy first with buf.slice(0) if you still need it
SharedArrayBuffer is not definedpage is not cross-origin isolated (COOP/COEP)
Atomics.wait throws on the main threaduse Atomics.waitAsync, or wait in a worker
Promise never settlesthe worker threw or was terminated; reject pending calls on error / terminate
Duplicate workers in React devStrict Mode mounts twice; terminate in the effect cleanup
DOM types inside worker codeseparate tsconfig with lib: ["webworker"]

Recipes

Typed request/response client

Call a worker like an async function, with the protocol.ts types above.

rpc-client.ts
import type * as P from "./protocol.ts";
type Out<R extends P.Req> = P.ResultOf[R["type"]];
type Waiter = PromiseWithResolvers<any>;
export function createRpc(worker: Worker) {
  let nextId = 0;
  const pending = new Map<number, Waiter>();
  worker.onmessage = (e: MessageEvent<P.Reply>) => {
    const r = e.data;
    const p = pending.get(r.id);
    pending.delete(r.id);
    if (r.ok) p?.resolve(r.result);
    else p?.reject(new Error(r.error));
  };
  worker.onerror = (e) => {
    for (const p of pending.values()) p.reject(e);
    pending.clear();
  };
  return <R extends P.Req>(req: R): Promise<Out<R>> => {
    const id = nextId++;
    const p = Promise.withResolvers<Out<R>>();
    pending.set(id, p);
    worker.postMessage({ id, req } satisfies P.Call);
    return p.promise;
  };
}
import { createRpc } from "./rpc-client.ts";
 
const call = createRpc(
  new Worker(new URL("./rpc.worker.ts", import.meta.url), {
    type: "module",
  }),
);
const total = await call({ type: "sum", numbers: [1, 2] });
//    ^? number

Worker pool

Spread many independent jobs across a fixed set of workers, queueing the rest; make keeps the new Worker(new URL(...)) pattern inline for the bundler.

pool.ts
type Job<I, O> = [I, PromiseWithResolvers<O>];
 
export function createPool<I, O>(make: () => Worker, n = 4) {
  const all = Array.from({ length: n }, make);
  const idle = [...all];
  const queue: Job<I, O>[] = [];
  const pump = () => {
    while (idle.length && queue.length) {
      const w = idle.pop()!;
      const [input, job] = queue.shift()!;
      const free = () => (idle.push(w), pump());
      w.onmessage = (e) => (job.resolve(e.data), free());
      w.onerror = (e) => (job.reject(e), free());
      w.postMessage(input);
    }
  };
  const run = (input: I) => {
    const job = Promise.withResolvers<O>();
    queue.push([input, job]);
    pump();
    return job.promise;
  };
  const close = () => all.forEach((w) => w.terminate());
  return { run, close };
}
import { createPool } from "./pool.ts";
 
const pool = createPool<number[], number>(
  () =>
    new Worker(new URL("./sum.worker.ts", import.meta.url), {
      type: "module",
    }),
  Math.max(1, navigator.hardwareConcurrency - 1),
);
const sums = await Promise.all(
  [[1, 2], [3, 4], [5, 6]].map((xs) => pool.run(xs)),
);
pool.close();

Transfer an ArrayBuffer round trip

Process a large binary payload in a worker without copying it either way.

gray.worker.ts
type Msg = { buf: ArrayBuffer };
 
self.onmessage = (e: MessageEvent<Msg>) => {
  const px = new Uint8ClampedArray(e.data.buf);
  for (let i = 0; i < px.length; i += 4) {
    const [r = 0, g = 0, b = 0] = px.subarray(i, i + 3);
    const y = 0.299 * r + 0.587 * g + 0.114 * b;
    px[i] = px[i + 1] = px[i + 2] = y;
  }
  self.postMessage({ buf: px.buffer }, [px.buffer]);
};
declare const ctx: CanvasRenderingContext2D;
declare const worker: Worker;
 
const img = ctx.getImageData(0, 0, 800, 600);
const buf = img.data.buffer;
worker.postMessage({ buf }, [buf]); // img.data now empty
 
type Msg = { buf: ArrayBuffer };
worker.onmessage = (e: MessageEvent<Msg>) => {
  const data = new Uint8ClampedArray(e.data.buf);
  ctx.putImageData(new ImageData(data, 800, 600), 0, 0);
};

Expose an object from the worker and call it with full types, including a progress callback.

api.worker.ts
import * as Comlink from "comlink";
 
const api = {
  async primes(max: number, report: (p: number) => void) {
    const out: number[] = [];
    for (let n = 2; n <= max; n++) {
      if (out.every((p) => n % p !== 0)) out.push(n);
      if (n % 10_000 === 0) await report(n / max);
    }
    return out;
  },
};
export type Api = typeof api;
Comlink.expose(api);
import * as Comlink from "comlink";
import type { Api } from "./api.worker.ts";
 
const worker = new Worker(
  new URL("./api.worker.ts", import.meta.url),
  { type: "module" },
);
const api = Comlink.wrap<Api>(worker);
const onProgress = (p: number) => console.log(`${p * 100}%`);
const primes = await api.primes(
  100_000,
  Comlink.proxy(onProgress), // callbacks go by reference
);
api[Comlink.releaseProxy]();
worker.terminate();

References