The first time I moved a CSV parser into a Web Worker, I was confident: the main thread would be free and INP would look great. The page still froze for almost half a second — it just froze somewhere else, when the worker posted back two hundred thousand objects and the main thread had to deserialize every one of them. Workers don't make heavy work disappear; they move it to another thread and charge a toll at both ends of the pipe. This post is what I've learned from a few times getting it right and many times getting it wrong: when a worker is worth it, what structured clone costs, how transferables and SharedArrayBuffer help, what cross-origin isolation demands, how to write a Comlink-style RPC layer yourself, bundler setup for Next.js and Vite, worker pools, cancellation with AbortSignal, real examples from CSV to WASM — and when scheduler.yield() is all you need.

When moving work to a worker pays off#

The browser's main thread does almost everything: runs JavaScript, handles input, computes style, layout and paint. A task that runs longer than 50ms is a long task — while it runs, the user's tap has to wait in line, and that wait is the input delay part of INP. If you want a refresher on tasks, microtasks and frames, I covered them in depth in The Event Loop, Microtasks and Rendering.

There are two ways to free the main thread: split the work so the browser can slot input and rendering in between, or move the work to another thread. Workers are the second option, and they fit best when the work:

  • Is CPU-heavy and doesn't need the DOM. Parsing, compression, encryption, search, geometry, image processing. Workers can't touch document or window.
  • Has large input and small output — or binary data that can move as a transferable. Send a File in, get a summary out.
  • Takes tens of milliseconds or more, repeatedly. The worker's startup cost and the data transfer have to be paid back by main-thread time saved.
  • Has long-lived state worth keeping separate. A search index, a WASM model — load once, query many times.

And workers don't help when:

  • The slow part is rendering. React reconciling ten thousand rows, layout of a huge DOM — a worker can't touch the DOM. That calls for virtualization, content-visibility, fewer nodes.
  • Data transfer outweighs the computation. Filtering a 5,000-item array takes 3ms; cloning it into a worker and cloning the result back can cost more.
  • The work is tiny but called constantly. Each postMessage round trip has a fixed cost; for sub-millisecond work, chunking on the main thread is cheaper.

Measure before you move. The Performance panel in DevTools shows you long tasks and who caused them; the Long Animation Frames API shows you the same thing on real users' machines. I only move code into a worker when a profile clearly shows it's what's hogging the main thread, and when what it returns is small enough.

A worker doesn't remove cost, it relocates it. Transfers are nearly free, structured clone scales with data size and runs on both threads — so keep messages small.
SituationWhat I chooseWhy
Parsing a CSV/JSON file of tens of MBWorker, send the File inLarge input, small summary out
Full-text search over tens of thousands of documentsLong-lived worker holding the indexIndex loads once; each query sends a short string
Compressing or resizing images before uploadWorker with OffscreenCanvasBinary data, returns a Blob
Filtering or sorting a few thousand table rowsMain thread, useDeferredValue or chunkingTransferring data costs more than computing
Rendering a long listVirtualizationThe slow part is the DOM, which a worker can't touch
Initializing many widgets on page loadscheduler.yield() between stepsNeeds the DOM; it just needs to give way

Three kinds of worker, and when to use which#

Dedicated WorkerShared WorkerService Worker
Owned byOne page (one document)Every same-origin tab that connectsThe origin, per scope
LifetimeUntil the page closes or terminate()While at least one tab is connectedStarted for events, stopped when idle
CommunicationpostMessage directlyThrough a MessagePort (worker.port)fetch, push, message events
Use forComputation, parsing, WASMSharing one connection or cache across tabsNetwork proxy, offline, push notifications
GotchasEach worker has its own heapMobile support has historically been uneven; harder to debugCan be stopped mid-flight; not a place for compute

In practice, 90% of "get it off the main thread" cases are a Dedicated Worker. I reach for a Shared Worker when several tabs need one expensive resource — a WebSocket connection, a SQLite WASM database — and accept that I need a fallback for browsers without support. Don't use a Service Worker for computation: the browser can stop it at any time when no event is pending, and everything it does sits on the path of your network requests.

There are also worklets — AudioWorklet, PaintWorklet — very narrow environments for exactly one pipeline. If you're doing real-time audio processing, that's the right place, not a Dedicated Worker.

The cost of postMessage: structured clone#

Every value that goes through postMessage is structured cloned: serialized on the sending thread, deserialized on the receiving one. Two things people often miss:

  1. Serialization runs synchronously on the sender. Calling worker.postMessage(bigArray) from the main thread means the main thread walks and copies the whole array before that call returns. You've just created a long task in order to... avoid a long task.
  2. The cost scales with the number and shape of objects, not just bytes. Two hundred thousand small objects are far more expensive than one ArrayBuffer of the same size.
TypeScript
// Measure the send cost right on the main thread
const rows = await loadRows(); // e.g. 200,000 objects { id, name, price, tags }

const t0 = performance.now();
worker.postMessage({ type: "index", rows });
console.log(`postMessage blocked the main thread for ${(performance.now() - t0).toFixed(1)}ms`);

Structured clone preserves more than JSON: Map, Set, Date, RegExp, BigInt, typed arrays, Blob, File, and even Error. But it can't clone functions, DOM nodes or Symbols, and class instances lose their prototype — a Money instance arrives as a plain object without its methods. Send something uncloneable and postMessage throws a DataCloneError right on the sending thread.

The rules I've ended up with:

  • Send references, not contents. File and Blob are cloned by reference and cost almost nothing. Send the File to the worker and read it there, instead of reading it into a string on the main thread and sending the string.
  • Let the worker fetch. If the data comes from the network, fetch and JSON.parse it inside the worker — the main thread never has to touch it.
  • Keep data in the worker. Send it once, then only pass small queries back and forth.
  • Return summaries. If the page shows 50 rows, don't return 200,000.

Transferables: move ownership instead of copying#

For binary data there's something better than cloning: transfer. Instead of copying, the browser hands ownership of the memory to the other thread, in near-constant time regardless of size. The sender loses access — the buffer becomes detached.

main.ts
const samples = new Float32Array(8_000_000); // about 32 MB
fillSamples(samples);

worker.postMessage({ type: "analyze", samples }, [samples.buffer]);

console.log(samples.byteLength); // 0 — the buffer belongs to the worker now

The transferables you'll use most: ArrayBuffer, MessagePort, ImageBitmap, OffscreenCanvas, streams (ReadableStream, WritableStream, TransformStream), VideoFrame and AudioData. You can also use structuredClone(value, { transfer }) to transfer within a single thread.

Transferring a subarray's buffer transfers the whole original buffer

subarray() creates a new view over the same ArrayBuffer. If you send chunk.buffer from a subarray, you transfer the entire underlying buffer — every other view on the main thread instantly becomes empty, and the worker receives more data than you meant to send. When you only want to send part of it, slice() it (which makes a separate copy) and transfer the copy, passing byteOffset/length along if the worker needs to know where it came from.

OffscreenCanvas: drawing in a worker#

One particularly useful transferable is OffscreenCanvas. You hand control of a <canvas> on the page to a worker, and the worker draws directly — the main thread takes no part in drawing. I used this for a real-time chart with tens of thousands of points: scrolling and zooming stayed smooth because both the computation and the drawing lived off the main thread.

components/live-chart.ts
const canvas = document.querySelector<HTMLCanvasElement>("#chart")!;
const offscreen = canvas.transferControlToOffscreen();

const worker = new Worker(new URL("./chart.worker.ts", import.meta.url), { type: "module" });
worker.postMessage({ type: "init", canvas: offscreen, dpr: devicePixelRatio }, [offscreen]);

// From here on, only small data and events cross the boundary
new ResizeObserver(([entry]) => {
  const { width, height } = entry.contentRect;
  worker.postMessage({ type: "resize", width, height });
}).observe(canvas);
components/chart.worker.ts
let ctx: OffscreenCanvasRenderingContext2D | null = null;
let canvas: OffscreenCanvas | null = null;
let dpr = 1;

self.onmessage = (event: MessageEvent) => {
  const msg = event.data;
  if (msg.type === "init") {
    canvas = msg.canvas as OffscreenCanvas;
    dpr = msg.dpr;
    ctx = canvas.getContext("2d");
  } else if (msg.type === "resize" && canvas) {
    canvas.width = Math.round(msg.width * dpr);
    canvas.height = Math.round(msg.height * dpr);
    draw();
  }
};

function draw() {
  if (!ctx || !canvas) return;
  ctx.clearRect(0, 0, canvas.width, canvas.height);
  // ... draw the series
}

Once you call transferControlToOffscreen(), you can no longer get a context for that canvas on the main thread. Its displayed size is still decided by CSS on the main thread; the worker only controls the pixel buffer.

SharedArrayBuffer, Atomics and cross-origin isolation#

A transfer moves ownership; only one thread holds the data at a time. SharedArrayBuffer is different: the same memory is visible to several threads at once. I've used it for three things:

  • Multithreaded WASM — toolchains compile C/C++/Rust threads to Web Workers sharing one memory.
  • Cancellation flags and progress without waiting for the worker's event loop to be free (more on that in the cancellation section).
  • Ring buffers for high-frequency data, such as audio between an AudioWorklet and a worker.

The requirement: cross-origin isolation#

After Spectre-class vulnerabilities, browsers only allow SharedArrayBuffer when the page is cross-origin isolated — you check with self.crossOriginIsolated. To get there, the document must be served with two headers:

HTTP
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
  • COOP same-origin severs the link between your page and any cross-origin windows that opened it or that it opens.
  • COEP require-corp requires every cross-origin resource the page loads to opt in, via CORS or a Cross-Origin-Resource-Policy header. The credentialless value is a softer option: cross-origin resources don't need CORP, but they're fetched without cookies.

The cost is very real:

What breaksWhyWhat to do
OAuth sign-in popups, payment popupsCOOP severs window.opener, so the popup can't report backEnable it only on routes that need SAB; use redirects instead of popups
Images, fonts, scripts from third-party CDNsWithout CORP or CORS they're blocked under require-corpUse credentialless, add crossorigin, or self-host
Embedded iframes (video, maps)Iframes must opt in tooThe iframe credentialless attribute where supported, or a separate page
Ads, chat widgetsUsually don't send CORPDon't enable isolation on those pages

The first time my team turned these headers on site-wide to run a multithreaded WASM library, the popup-based "Sign in with Google" button stopped working and every embedded video vanished. Lesson learned: enable cross-origin isolation only on the routes that need it.

next.config.ts
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  async headers() {
    return [
      {
        // Only the editor needs SharedArrayBuffer
        source: "/editor/:path*",
        headers: [
          { key: "Cross-Origin-Opener-Policy", value: "same-origin" },
          { key: "Cross-Origin-Embedder-Policy", value: "credentialless" },
        ],
      },
    ];
  },
};

export default nextConfig;

Note that a client-side navigation from a non-isolated page to /editor won't make the page isolated — the headers only take effect when the document loads. Links into the editor should be full navigations (a plain a element, not the router), and always check crossOriginIsolated before using SharedArrayBuffer, with a fallback for when it's false.

Atomics: safe reads and writes on shared memory#

TypeScript
// main.ts — a shared cancel flag and progress counter
const control = new Int32Array(new SharedArrayBuffer(8)); // [0] = cancel flag, [1] = progress (per mille)
worker.postMessage({ type: "crunch", control, input });

cancelButton.onclick = () => Atomics.store(control, 0, 1);

function renderProgress() {
  progressBar.value = Atomics.load(control, 1) / 1000;
  if (!done) requestAnimationFrame(renderProgress);
}
requestAnimationFrame(renderProgress);
crunch.worker.ts
function crunch(control: Int32Array, input: Float64Array) {
  for (let i = 0; i < input.length; i++) {
    if ((i & 0xfff) === 0) {
      if (Atomics.load(control, 0) === 1) throw new DOMException("Cancelled", "AbortError");
      Atomics.store(control, 1, Math.floor((i / input.length) * 1000));
    }
    // ... heavy synchronous computation
  }
}

The nice part: a worker stuck in a synchronous loop still sees the cancel flag immediately, because it reads memory rather than waiting for a message. Note that Atomics.wait() isn't allowed on the browser's main thread (it would block the UI); on the main thread, use Atomics.waitAsync() where supported.

Raw worker code usually starts as a switch (event.data.type) and ends as a pile of message types nobody remembers. Comlink solves exactly this by turning a worker into an object whose methods return promises. I recommend Comlink for most projects — but writing a small version once teaches you exactly what goes through the pipe, and lets you add things Comlink doesn't give you out of the box: cancellation, progress, and cleanup when a worker dies.

The protocol#

lib/rpc/protocol.ts
export type Call = { kind: "call"; id: number; method: string; input: unknown };
export type Cancel = { kind: "cancel"; id: number };
export type Progress = { kind: "progress"; id: number; value: number };
export type SerializedError = { name: string; message: string; stack?: string };
export type Result =
  | { kind: "result"; id: number; ok: true; value: unknown }
  | { kind: "result"; id: number; ok: false; error: SerializedError };

export type TaskContext = { signal: AbortSignal; progress(value: number): void };
export type Api = Record<string, (input: any, ctx: TaskContext) => unknown>;

// Mark values that should be transferred instead of cloned
const marked = new WeakMap<object, Transferable[]>();

export function transfer<T extends object>(value: T, list: Transferable[]): T {
  marked.set(value, list);
  return value;
}

export function takeTransfer(value: unknown): Transferable[] {
  if (typeof value !== "object" || value === null) return [];
  const list = marked.get(value) ?? [];
  marked.delete(value);
  return list;
}

Every API function takes exactly one input and a TaskContext. That constraint is deliberate: it keeps the types simple and leaves room for signal and progress without guessing what the last argument is.

The worker side: expose#

lib/rpc/expose.ts
/// <reference lib="webworker" />
import { takeTransfer, type Api, type Call, type Cancel, type Result, type TaskContext } from "./protocol";

declare const self: DedicatedWorkerGlobalScope;

export function expose(api: Api) {
  const running = new Map<number, AbortController>();

  self.addEventListener("message", async (event: MessageEvent<Call | Cancel>) => {
    const msg = event.data;
    if (msg.kind === "cancel") {
      running.get(msg.id)?.abort(new DOMException("Cancelled", "AbortError"));
      return;
    }

    const controller = new AbortController();
    running.set(msg.id, controller);
    const ctx: TaskContext = {
      signal: controller.signal,
      progress: (value) => self.postMessage({ kind: "progress", id: msg.id, value }),
    };

    try {
      const fn = api[msg.method];
      if (!fn) throw new Error(`Unknown method "${msg.method}"`);
      const value = await fn(msg.input, ctx);
      self.postMessage({ kind: "result", id: msg.id, ok: true, value } satisfies Result, takeTransfer(value));
    } catch (err) {
      const e = err instanceof Error ? err : new Error(String(err));
      self.postMessage({
        kind: "result",
        id: msg.id,
        ok: false,
        error: { name: e.name, message: e.message, stack: e.stack },
      } satisfies Result);
    } finally {
      running.delete(msg.id);
    }
  });
}

The main-thread side: wrap#

lib/rpc/wrap.ts
import { takeTransfer, type Api, type Call, type Progress, type Result } from "./protocol";

export type CallOptions = { signal?: AbortSignal; onProgress?: (value: number) => void };
export type Remote<T extends Api> = {
  [K in keyof T]: (input: Parameters<T[K]>[0], opts?: CallOptions) => Promise<Awaited<ReturnType<T[K]>>>;
};

type Pending = { resolve(v: unknown): void; reject(e: unknown): void; onProgress?: (v: number) => void };

export function wrap<T extends Api>(worker: Worker) {
  let nextId = 1;
  const pending = new Map<number, Pending>();

  worker.addEventListener("message", (event: MessageEvent<Result | Progress>) => {
    const msg = event.data;
    const p = pending.get(msg.id);
    if (!p) return; // cancelled or already settled
    if (msg.kind === "progress") return p.onProgress?.(msg.value);
    pending.delete(msg.id);
    if (msg.ok) p.resolve(msg.value);
    else p.reject(Object.assign(new Error(msg.error.message), msg.error));
  });

  const failAll = (reason: unknown) => {
    for (const p of pending.values()) p.reject(reason);
    pending.clear();
  };
  worker.addEventListener("error", (e) => failAll(new Error(`Worker crashed: ${e.message}`)));
  worker.addEventListener("messageerror", () => failAll(new Error("Could not deserialize worker message")));

  const api = new Proxy({} as Remote<T>, {
    get(_target, method) {
      // Without this line, `await api` treats the proxy as a thenable and hangs forever
      if (method === "then" || typeof method !== "string") return undefined;

      return (input: unknown, { signal, onProgress }: CallOptions = {}) =>
        new Promise((resolve, reject) => {
          if (signal?.aborted) return reject(signal.reason);
          const id = nextId++;
          const onAbort = () => {
            pending.delete(id);
            worker.postMessage({ kind: "cancel", id });
            reject(signal!.reason);
          };
          signal?.addEventListener("abort", onAbort, { once: true });
          const cleanup = () => signal?.removeEventListener("abort", onAbort);

          pending.set(id, {
            resolve: (v) => (cleanup(), resolve(v)),
            reject: (e) => (cleanup(), reject(e)),
            onProgress,
          });
          worker.postMessage({ kind: "call", id, method, input } satisfies Call, takeTransfer(input));
        });
    },
  });

  const dispose = (reason: unknown = new DOMException("Worker disposed", "AbortError")) => {
    worker.terminate();
    failAll(reason);
  };

  return { api, dispose };
}

Three details worth calling out:

  • The Proxy then trap. The proxy returns a function for every property name, including then. When someone awaits the proxy (or returns it from an async function), JavaScript treats it as a thenable and calls then — an RPC call that never resolves. Comlink has to handle exactly the same thing.
  • error and messageerror. If a worker crashes (a syntax error, out of memory) and pending promises aren't rejected, your UI spins forever.
  • dispose rejects every pending promise. terminate() on its own doesn't — the promises simply hang.

Using it is compact and fully typed:

workers/math.worker.ts
import { expose } from "@/lib/rpc/expose";
import type { TaskContext } from "@/lib/rpc/protocol";

const api = {
  async primes(limit: number, { signal, progress }: TaskContext) {
    const found: number[] = [];
    for (let n = 2; n <= limit; n++) {
      if (n % 50_000 === 0) {
        progress(n / limit);
        await new Promise((r) => setTimeout(r)); // yield so the cancel message can arrive
        signal.throwIfAborted();
      }
      if (isPrime(n)) found.push(n);
    }
    return found.length;
  },
};

export type MathApi = typeof api;
expose(api);

function isPrime(n: number) {
  for (let d = 2; d * d <= n; d++) if (n % d === 0) return false;
  return true;
}
main.ts
import { wrap } from "@/lib/rpc/wrap";
import type { MathApi } from "@/workers/math.worker";

const { api, dispose } = wrap<MathApi>(
  new Worker(new URL("./workers/math.worker.ts", import.meta.url), { type: "module" }),
);

const controller = new AbortController();
const count = await api.primes(5_000_000, {
  signal: controller.signal,
  onProgress: (p) => (bar.value = p),
});

import type pulls in only the types, not the worker's code — a small detail, but forget it and you bundle the worker twice.

Bundler setup: Next.js, Vite and TypeScript#

The standard pattern recognized by Vite, webpack 5 and Turbopack alike:

TypeScript
const worker = new Worker(new URL("./search.worker.ts", import.meta.url), { type: "module" });

The bundler sees new URL(..., import.meta.url) placed directly inside new Worker(...), splits that file into its own entry, compiles the TypeScript and swaps in a hashed URL. The catch is that the expression must be written literally like that: put the path in a variable, build the string dynamically or wrap it in a helper function, and the bundler no longer recognizes it — you get a 404 in production.

Vite#

vite.config.ts
import { defineConfig } from "vite";

export default defineConfig({
  worker: {
    // The default is "iife"; "es" is required once a worker uses dynamic imports / code splitting
    format: "es",
  },
});

Vite also has its own import SearchWorker from "./search.worker?worker" syntax. It's convenient but non-standard — I prefer the new URL pattern so the code works across bundlers.

Next.js: create workers on the client only#

Workers don't exist on the server. Creating one at the top level of a module in a Client Component still runs during server rendering and throws Worker is not defined. The right place is inside useEffect, or lazily in an event handler.

hooks/use-worker.ts
"use client";

import { useEffect, useRef } from "react";
import { wrap, type Remote } from "@/lib/rpc/wrap";
import type { Api } from "@/lib/rpc/protocol";

export function useWorker<T extends Api>(create: () => Worker) {
  const ref = useRef<Remote<T> | null>(null);

  useEffect(() => {
    const { api, dispose } = wrap<T>(create());
    ref.current = api;
    // StrictMode runs effects twice in dev — the cleanup guarantees we don't leak workers
    return () => {
      ref.current = null;
      dispose();
    };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  return ref;
}

// Usage: the new URL pattern must appear literally here for the bundler to see it
// const search = useWorker<SearchApi>(() =>
//   new Worker(new URL("../workers/search.worker.ts", import.meta.url), { type: "module", name: "search" }),
// );

The name option shows the worker's name in DevTools — well worth adding once you have more than one worker.

TypeScript for worker files#

Worker files run in a DedicatedWorkerGlobalScope, not a Window. The quickest fix is /// <reference lib="webworker" /> at the top of the file plus a self declaration, as in expose.ts above. On larger projects I add a separate tsconfig.worker.json with "lib": ["ES2023", "WebWorker"] for *.worker.ts files, so the IDE flags it immediately when someone reaches for document inside a worker.

CSP applies to workers too

If your site has a Content Security Policy, workers are governed by the worker-src directive (falling back to script-src and then default-src when it's missing). Workers created from blob: URLs — some libraries do this to inline a worker — need blob: in worker-src. A same-origin worker with its own URL, like the new URL pattern above, only needs 'self'.

Worker pools: controlled parallelism#

A worker runs one job at a time — resize ten images and nine of them wait in line. But spawning a new worker per job pays the startup cost every time: creating the thread, loading and parsing the script, initializing a heap. The answer is a pool: a bounded number of workers, created lazily, reused, and shut down after sitting idle.

lib/rpc/pool.ts
import { wrap, type Remote } from "./wrap";
import type { Api } from "./protocol";

type Slot<T extends Api> = {
  api: Remote<T>;
  dispose(reason?: unknown): void;
  dead: boolean;
  idleTimer?: ReturnType<typeof setTimeout>;
};

type Job<T extends Api> = {
  method: keyof T & string;
  input: unknown;
  signal?: AbortSignal;
  resolve(value: unknown): void;
  reject(reason: unknown): void;
};

const defaultSize = () => Math.max(1, Math.min(4, (navigator.hardwareConcurrency || 2) - 1));

export class WorkerPool<T extends Api> {
  private idle: Slot<T>[] = [];
  private queue: Job<T>[] = [];
  private live = 0;

  constructor(
    private spawn: () => Worker,
    private max = defaultSize(),
    private idleMs = 30_000,
  ) {}

  run<K extends keyof T & string>(
    method: K,
    input: Parameters<T[K]>[0],
    { signal }: { signal?: AbortSignal } = {},
  ): Promise<Awaited<ReturnType<T[K]>>> {
    return new Promise((resolve, reject) => {
      if (signal?.aborted) return reject(signal.reason);
      const job: Job<T> = { method, input, signal, resolve: resolve as Job<T>["resolve"], reject };
      // Still queued: just pull it out
      signal?.addEventListener(
        "abort",
        () => {
          const i = this.queue.indexOf(job);
          if (i !== -1) {
            this.queue.splice(i, 1);
            reject(signal.reason);
          }
        },
        { once: true },
      );
      this.queue.push(job);
      this.pump();
    });
  }

  private pump() {
    while (this.queue.length) {
      const slot = this.idle.pop() ?? (this.live < this.max ? this.create() : undefined);
      if (!slot) return;
      clearTimeout(slot.idleTimer);
      void this.execute(slot, this.queue.shift()!);
    }
  }

  private create(): Slot<T> {
    this.live++;
    const { api, dispose } = wrap<T>(this.spawn());
    return { api, dispose, dead: false };
  }

  private async execute(slot: Slot<T>, job: Job<T>) {
    // Already running: the only guaranteed cancellation is killing the worker
    const onAbort = () => {
      slot.dead = true;
      slot.dispose(job.signal!.reason);
    };
    job.signal?.addEventListener("abort", onAbort, { once: true });
    try {
      job.resolve(await slot.api[job.method](job.input as never));
    } catch (err) {
      job.reject(err);
    } finally {
      job.signal?.removeEventListener("abort", onAbort);
      if (slot.dead) this.live--;
      else this.release(slot);
      this.pump();
    }
  }

  private release(slot: Slot<T>) {
    slot.idleTimer = setTimeout(() => {
      this.idle = this.idle.filter((s) => s !== slot);
      this.live--;
      slot.dispose();
    }, this.idleMs);
    this.idle.push(slot);
  }

  destroy() {
    for (const slot of this.idle) slot.dispose();
    this.idle = [];
    for (const job of this.queue.splice(0)) job.reject(new DOMException("Pool destroyed", "AbortError"));
  }
}

Pool size is where it's easy to overdo things:

  • navigator.hardwareConcurrency isn't the number of cores you get. Browsers may round or cap it for privacy, and the main thread, compositor and GPU need CPU too. I subtract one and set a low ceiling, especially on mobile.
  • Every worker is a separate JavaScript heap. Four workers loading the same 2 MB library means four copies in memory. On a low-end phone, that's a fast track to the OS killing the tab.
  • Don't pool stateful workers. A search index lives in one worker; a pool of four means four copies of the index, or queries landing on a worker that doesn't have it. Long-lived stateful workers should be their own dedicated worker.

Cancellation with AbortSignal#

Cancellation is what most worker examples online skip, yet in a real UI it happens constantly: the user types another character, changes a filter, leaves the page.

AbortSignal can't be cloned through postMessage, so you can't send the signal to the worker. Instead, the main thread listens to the signal and sends a cancel message — exactly what wrap does above. But there's a trap that cost me an afternoon to understand:

Workers have an event loop too — and it can be blocked

If a function in the worker runs a five-second synchronous loop, the cancel message sits in the worker's queue for those five seconds and is never handled until the loop ends. Message-based cancellation only works if the worker code yields from time to time (an await that genuinely goes through the event loop, such as reading a stream or a setTimeout) and then checks signal.aborted.

So there are three ways to cancel, in the order I usually reach for them:

  1. Yield and check the signal inside the worker — like the primes function above, or naturally when you read data as a stream. Cheap; the worker stays alive with its state intact.
  2. A flag in a SharedArrayBuffer read with Atomics.load — instant even inside a synchronous loop, but requires cross-origin isolation.
  3. terminate() and spawn a new worker — always works, no matter what the code inside is doing. In exchange, you lose state and pay startup again. The pool above uses this for running jobs.

The typeahead pattern I use most — each keystroke cancels the previous query:

hooks/use-search.ts
let current: AbortController | null = null;

export async function search(q: string) {
  current?.abort(new DOMException("Superseded", "AbortError"));
  const controller = (current = new AbortController());
  try {
    return await searchApi.query({ q, limit: 20 }, { signal: controller.signal });
  } catch (err) {
    if (err instanceof DOMException && err.name === "AbortError") return null; // superseded, not an error
    throw err;
  }
}

Real examples#

Parsing a large CSV file#

The classic mistake: await file.text() on the main thread, then sending a 200 MB string to the worker. Instead, send the File itself — it's cloned by reference — and stream it inside the worker. Streaming has a bonus: every await reader.read() is a yield point, so cancel messages get handled mid-way.

workers/csv.worker.ts
import { expose } from "@/lib/rpc/expose";
import { transfer, type TaskContext } from "@/lib/rpc/protocol";

type Summary = { rows: number; revenueByCountry: Map<string, number>; amounts: Float64Array };

const api = {
  async summarize(file: File, { signal, progress }: TaskContext): Promise<Summary> {
    const reader = file.stream().pipeThrough(new TextDecoderStream()).getReader();
    const revenueByCountry = new Map<string, number>();
    let amounts = new Float64Array(1 << 16);
    let rows = 0;
    let carry = "";
    let header: string[] | null = null;
    let readChars = 0;
    let lastReported = 0;

    for (;;) {
      const { value, done } = await reader.read();
      signal.throwIfAborted();
      if (done) break;

      readChars += value.length;
      const lines = (carry + value).split("\n");
      carry = lines.pop() ?? "";

      for (const line of lines) {
        // Simplified: doesn't handle commas inside quotes.
        // Real data deserves a proper CSV parser — still running inside the worker.
        const cols = line.split(",");
        if (!header) {
          header = cols;
          continue;
        }
        const amount = Number(cols[3]);
        if (rows === amounts.length) {
          const bigger = new Float64Array(amounts.length * 2);
          bigger.set(amounts);
          amounts = bigger;
        }
        amounts[rows++] = amount;
        revenueByCountry.set(cols[2], (revenueByCountry.get(cols[2]) ?? 0) + amount);
      }

      const ratio = readChars / file.size; // approximate: characters vs bytes
      if (ratio - lastReported > 0.01) {
        progress(Math.min(ratio, 1));
        lastReported = ratio;
      }
    }

    const result = { rows, revenueByCountry, amounts: amounts.slice(0, rows) };
    return transfer(result, [result.amounts.buffer]);
  },
};

export type CsvApi = typeof api;
expose(api);

What comes back is a small Map and a transferred Float64Array — not an array of two hundred thousand objects. The main thread receives it almost instantly and draws the chart from columnar data. That's exactly the lesson from the mistake at the start of this post.

A search index that lives in a worker#

workers/search.worker.ts
import MiniSearch from "minisearch";
import { expose } from "@/lib/rpc/expose";

type Doc = { id: string; title: string; body: string; url: string };
let index: MiniSearch<Doc> | null = null;
const titles = new Map<string, { title: string; url: string }>();

const api = {
  async load(url: string) {
    // fetch and JSON.parse inside the worker: the main thread never touches tens of MB of JSON
    const docs: Doc[] = await (await fetch(url)).json();
    index = new MiniSearch<Doc>({ fields: ["title", "body"], storeFields: [] });
    index.addAll(docs);
    for (const d of docs) titles.set(d.id, { title: d.title, url: d.url });
    return docs.length;
  },

  query({ q, limit }: { q: string; limit: number }) {
    if (!index) throw new Error("Index not loaded");
    return index
      .search(q, { prefix: true, fuzzy: 0.2 })
      .slice(0, limit)
      .map((hit) => ({ id: hit.id as string, score: hit.score, ...titles.get(hit.id as string)! }));
  },
};

export type SearchApi = typeof api;
expose(api);

This is the textbook "data stays in the worker" case: documents enter the worker once over the network, and each query sends a short string and gets 20 results back. It's also why this worker is a dedicated one rather than part of a pool.

Processing images before upload#

workers/image.worker.ts
import { expose } from "@/lib/rpc/expose";

const api = {
  async thumbnail({ file, maxSize }: { file: Blob; maxSize: number }) {
    const bitmap = await createImageBitmap(file);
    const scale = Math.min(1, maxSize / Math.max(bitmap.width, bitmap.height));
    const canvas = new OffscreenCanvas(Math.round(bitmap.width * scale), Math.round(bitmap.height * scale));
    const ctx = canvas.getContext("2d")!;
    ctx.imageSmoothingQuality = "high";
    ctx.drawImage(bitmap, 0, 0, canvas.width, canvas.height);
    bitmap.close();
    return canvas.convertToBlob({ type: "image/webp", quality: 0.85 });
  },
};

export type ImageApi = typeof api;
expose(api);
upload.ts
const pool = new WorkerPool<ImageApi>(
  () => new Worker(new URL("./workers/image.worker.ts", import.meta.url), { type: "module" }),
);

const thumbs = await Promise.all(files.map((file) => pool.run("thumbnail", { file, maxSize: 1600 })));

Both input and output are Blobs, passing through the pipe by reference. Decoding, resizing and encoding — the expensive parts — happen entirely off the main thread. It's stateless work, so a pool is the natural fit.

WebAssembly in a worker#

WASM and workers are a natural pair: WASM modules usually do CPU-heavy work, and compiling the module takes time too. Putting both in a worker keeps the main thread completely clean.

workers/wasm.worker.ts
import { expose } from "@/lib/rpc/expose";
import { transfer } from "@/lib/rpc/protocol";

type Exports = {
  memory: WebAssembly.Memory;
  alloc(len: number): number;
  free(ptr: number, len: number): void;
  blur(ptr: number, width: number, height: number, radius: number): void;
};

// Compile once when the worker starts; the server must send Content-Type: application/wasm
const ready = WebAssembly.instantiateStreaming(fetch(new URL("./image_ops.wasm", import.meta.url))).then(
  ({ instance }) => instance.exports as unknown as Exports,
);

const api = {
  async blur({ pixels, width, height, radius }: { pixels: Uint8ClampedArray; width: number; height: number; radius: number }) {
    const wasm = await ready;
    const len = pixels.byteLength;
    const ptr = wasm.alloc(len);
    // Read memory.buffer AFTER alloc: memory may have grown and detached the old buffer
    new Uint8ClampedArray(wasm.memory.buffer, ptr, len).set(pixels);
    wasm.blur(ptr, width, height, radius);
    const out = new Uint8ClampedArray(wasm.memory.buffer.slice(ptr, ptr + len));
    wasm.free(ptr, len);
    return transfer(out, [out.buffer]);
  },
};

export type WasmApi = typeof api;
expose(api);

In practice, toolchains like wasm-bindgen or Emscripten generate this glue for you; the example is just to show where the data goes. Note the memory.buffer detail: when WASM memory grows, every view created before that points at a detached buffer — a baffling bug if you haven't seen it before. And multithreaded WASM requires SharedArrayBuffer, so everything in the cross-origin isolation section applies.

When you don't need a worker yet: scheduler.yield, postTask and chunking#

Most long tasks I run into don't need a worker. They're DOM-bound work — initializing widgets, rendering, measuring layout — or moderate work that just needs to give way so the browser can handle input. The Event Loop, Microtasks and Rendering goes deep on the mechanics; here's the toolkit I actually use.

lib/yield.ts
type SchedulerLike = {
  yield?: () => Promise<void>;
  postTask?: <T>(cb: () => T, opts?: { priority?: string; signal?: AbortSignal; delay?: number }) => Promise<T>;
};
const scheduler = (globalThis as { scheduler?: SchedulerLike }).scheduler;

export function yieldToMain(): Promise<void> {
  // scheduler.yield() gives way, but the continuation runs ahead of other queued tasks
  if (scheduler?.yield) return scheduler.yield();
  return new Promise((resolve) => setTimeout(resolve, 0));
}

export async function processInChunks<T>(items: T[], fn: (item: T) => void, budgetMs = 8) {
  let deadline = performance.now() + budgetMs;
  for (const item of items) {
    fn(item);
    if (performance.now() > deadline) {
      await yieldToMain();
      deadline = performance.now() + budgetMs;
    }
  }
}

scheduler.postTask() lets you set a priority — user-blocking, user-visible, background — and cancel with a signal. I use it to push non-urgent work (prefetching data, building a secondary index) to later:

TypeScript
if (scheduler?.postTask) {
  const controller = new AbortController();
  scheduler.postTask(() => warmUpCaches(), { priority: "background", signal: controller.signal });
  // leaving the page or no longer needed: controller.abort()
} else {
  globalThis.requestIdleCallback?.(() => warmUpCaches());
}

Support for scheduler.yield() and scheduler.postTask() varies between browsers, so always feature-detect as above. In React, useTransition and useDeferredValue give you the same idea at the render level: expensive updates are marked as interruptible so input gets handled first.

ApproachWhere the work runsTotal timeUse when
Chunking + scheduler.yield()Main threadSame or slightly longerDOM-bound or moderate work
scheduler.postTask background / requestIdleCallbackMain thread, when idleSame, but laterNon-urgent work
useTransition / useDeferredValueMain thread, interruptible by ReactSameExpensive re-renders driven by input
Web WorkerSeparate threadPlus data transfer costCPU-heavy, no DOM, small output
Worker + WASMSeparate thread, native codeOften shorter for numeric workHeavy algorithms with an existing C/C++/Rust implementation

Mistakes I keep seeing#

Returning huge raw data. The worker finishes parsing and then postMessages two hundred thousand objects — the main thread pays to deserialize them, and the long task is back at the other end of the pipe.

Reading the file into a string on the main thread before sending it. Send the File or Blob and read it in the worker.

Spawning a new worker per call. You pay startup every time; use a long-lived worker or a pool.

Forgetting terminate() in effect cleanup. StrictMode runs effects twice in dev, orphaning a worker each time; in production, every remount adds another worker.

Moving DOM-bound work into a worker. If the slow part is rendering, a worker can't help; you need virtualization or a smaller DOM.

Using a buffer after transferring it. It's detached, with a length of zero. And transferring a subarray's .buffer transfers the whole original buffer.

Not handling error and messageerror. The worker crashes and the UI spins forever.

Cancelling by message while the worker runs a synchronous loop. The message never gets read in time; yield, use a SAB flag, or terminate().

Turning on COOP/COEP site-wide. Sign-in popups, embedded videos and CDN images break one after another. Enable it only on the routes that need it.

Creating a worker at module top level in Next.js. Worker is not defined during server rendering.

Putting the worker path in a variable. The bundler doesn't recognize the pattern, the worker file isn't built, and you get a 404 in production.

Summary#

  • Measure first: only move work to a worker when a profile shows CPU-heavy, DOM-free work with small enough output.
  • Send references (File, Blob), let the worker fetch and parse, keep data in the worker, return summaries.
  • Use transferables for binary data and OffscreenCanvas; remember buffers become detached, and watch out for subarrays.
  • Only use SharedArrayBuffer when you truly need it; enable COOP/COEP on specific routes, check crossOriginIsolated, and have a fallback.
  • Wrap workers in a typed RPC (Comlink or your own): handle the Proxy then trap, error, messageerror, and reject every pending promise on dispose.
  • Write new Worker(new URL("./x.worker.ts", import.meta.url), { type: "module" }) literally; create workers in useEffect and terminate() them in cleanup.
  • Pool stateless work with a small, capped size; stateful workers (indexes, models) are dedicated and long-lived.
  • Cancel by yielding and checking the signal in the worker, with a SAB flag when you have isolation, and with terminate() when you need a guarantee.
  • For DOM-bound or moderate work: chunk with scheduler.yield(), use postTask for background work and useTransition for rendering.
  • Verify with real INP on low-end devices, not with a feeling that "the main thread is free now".