Skip to content

Web Workers for Parsing and Embedding

11 min read · updated August 4, 2026

The main thread renders. Anything else it does, it does instead of rendering. A Web Worker is a second thread with its own event loop, no DOM access, and a message-passing boundary — and the cost of crossing that boundary is what decides whether moving work there actually helps.

The 16ms budget

At 60 frames per second the browser has 16.7ms per frame to run your JavaScript, recalculate styles, lay out, paint and composite. A synchronous task longer than that drops a frame. Longer than about 50ms and interaction feels broken; longer than a few hundred and the browser may offer to close the page.

Rough costs for the steps in a client-side document pipeline.
Order of magnitude, not benchmarks — measure your own with
performance.now(), which is the point of the numbers below.

  parse a 2MB PDF to text                     hundreds of ms to seconds
  chunk 200 pages into overlapping windows     tens to hundreds of ms
  tokenise 500k characters in JS               hundreds of ms
  cosine similarity, 10k vectors × 384 dims    tens of ms
  cosine similarity, 200k vectors × 768 dims   seconds
  JSON.parse a 20MB embedding cache            hundreds of ms

Every one of those is over one frame. Every one of them freezes
the page for its whole duration if it runs on the main thread.

The decision rule is simple and it is the same one every time. If a synchronous task can exceed roughly 50ms on the slowest device you support, it belongs in a worker. Note the last clause: a task that takes 20ms on a development laptop can take 150ms on a mid-range phone, which is where your users are.

// Measure before you move anything. This is the whole methodology.
const t0 = performance.now();
const chunks = chunkDocument(text);
console.log("chunking", (performance.now() - t0).toFixed(1), "ms");

// And in production, sampled: long tasks are reported by the platform.
new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    if (entry.duration > 50) {
      report("long-task", { duration: Math.round(entry.duration) });
    }
  }
}).observe({ entryTypes: ["longtask"] });

A worker with a typed protocol

The message boundary is untyped by default, which turns every refactoring mistake into a runtime bug on a thread you cannot easily step through. Define the protocol as a discriminated union first, and the compiler polices both sides.

// protocol.ts — imported by both threads, so they cannot disagree.
export type Request =
  | { id: number; type: "chunk"; text: string; size: number; overlap: number }
  | { id: number; type: "similarity"; query: Float32Array; matrix: Float32Array; dims: number }
  | { id: number; type: "cancel"; target: number };

export type Response =
  | { id: number; type: "chunked"; chunks: string[] }
  | { id: number; type: "scores"; scores: Float32Array }
  | { id: number; type: "progress"; done: number; total: number }
  | { id: number; type: "error"; message: string };
// worker.ts
/// <reference lib="webworker" />
import type { Request, Response } from "./protocol";

const cancelled = new Set<number>();

function reply(msg: Response, transfer: Transferable[] = []) {
  (self as unknown as Worker).postMessage(msg, transfer);
}

self.onmessage = (event: MessageEvent<Request>) => {
  const msg = event.data;

  if (msg.type === "cancel") {
    cancelled.add(msg.target);
    return;
  }

  try {
    if (msg.type === "chunk") {
      const chunks: string[] = [];
      const step = Math.max(1, msg.size - msg.overlap);

      for (let i = 0; i < msg.text.length; i += step) {
        // Cooperative cancellation: the worker cannot be interrupted, so it
        // has to check. There is no terminate-and-resume.
        if (cancelled.has(msg.id)) {
          cancelled.delete(msg.id);
          return;
        }
        chunks.push(msg.text.slice(i, i + msg.size));

        if (chunks.length % 200 === 0) {
          reply({ id: msg.id, type: "progress", done: i, total: msg.text.length });
        }
      }

      reply({ id: msg.id, type: "chunked", chunks });
      return;
    }

    if (msg.type === "similarity") {
      const count = msg.matrix.length / msg.dims;
      const scores = new Float32Array(count);

      for (let v = 0; v < count; v++) {
        let dot = 0;
        const base = v * msg.dims;
        for (let d = 0; d < msg.dims; d++) {
          dot += msg.query[d] * msg.matrix[base + d];
        }
        scores[v] = dot;              // assumes both sides are normalised
      }

      // Transfer the result rather than copying it back.
      reply({ id: msg.id, type: "scores", scores }, [scores.buffer]);
      return;
    }
  } catch (err) {
    reply({ id: msg.id, type: "error", message: (err as Error).message });
  }
};
// client.ts — a promise-based wrapper over the message protocol.
import type { Request, Response } from "./protocol";

export function createWorkerClient() {
  // The new URL(...) form is what lets a bundler find and build the worker.
  const worker = new Worker(new URL("./worker.ts", import.meta.url), {
    type: "module",
  });

  let nextId = 1;
  const pending = new Map<
    number,
    { resolve: (r: Response) => void; reject: (e: Error) => void;
      onProgress?: (done: number, total: number) => void }
  >();

  worker.onmessage = (event: MessageEvent<Response>) => {
    const msg = event.data;
    const entry = pending.get(msg.id);
    if (!entry) return;

    if (msg.type === "progress") {
      entry.onProgress?.(msg.done, msg.total);
      return;                                  // not a resolution
    }

    pending.delete(msg.id);
    if (msg.type === "error") entry.reject(new Error(msg.message));
    else entry.resolve(msg);
  };

  worker.onerror = (event) => {
    // A worker-level error rejects everything outstanding; nothing else will.
    for (const [, entry] of pending) entry.reject(new Error(event.message));
    pending.clear();
  };

  function send(
    req: Omit<Request, "id">,
    transfer: Transferable[] = [],
    onProgress?: (done: number, total: number) => void,
  ): Promise<Response> {
    const id = nextId++;
    return new Promise((resolve, reject) => {
      pending.set(id, { resolve, reject, onProgress });
      worker.postMessage({ ...req, id } as Request, transfer);
    });
  }

  return {
    send,
    cancel: (id: number) => worker.postMessage({ id: 0, type: "cancel", target: id }),
    terminate: () => worker.terminate(),
  };
}

Transfer, do not copy

This is the part that decides whether the worker helps. By default, postMessage uses the structured clone algorithm: it makes a deep copy of the data, on the sending thread, synchronously. So moving work off the main thread can cost you a main-thread copy of the input and a main-thread copy of the output — and for large arrays the copying can exceed the work you moved.

Copying versus transferring a 100,000 × 384-dimension embedding matrix
in Float32:

  bytes = 100,000 × 384 × 4  =  153.6 MB

  structured clone: allocate 153.6 MB and copy it, on the sending
  thread, synchronously. Tens to hundreds of milliseconds — the exact
  freeze you were trying to remove.

  transfer: the ArrayBuffer's ownership moves to the other thread.
  No copy. Effectively constant time regardless of size.

The cost of the transfer is that the buffer becomes unusable on the
sending side — byteLength goes to 0. That is not a bug, it is the
mechanism: exactly one thread owns it.
// Transferring: pass the underlying ArrayBuffers as the second argument.
const query = new Float32Array(384);
const matrix = new Float32Array(100_000 * 384);

worker.postMessage(
  { id: 1, type: "similarity", query, matrix, dims: 384 },
  [query.buffer, matrix.buffer],          // <- ownership moves
);

console.log(matrix.byteLength);           // 0 — this side no longer owns it

// If both threads need it, use a SharedArrayBuffer instead. It requires
// cross-origin isolation headers (COOP and COEP) on the document, which is
// a deployment decision rather than a code one — check before designing
// around it.

The rule that falls out: send ArrayBuffer and typed arrays and transfer them; avoid sending large object graphs, which cannot be transferred and must be cloned. If your data is a large array of objects, converting it to a flat typed array before sending is usually worth doing purely for this reason.

A pool, and cancellation

One worker serialises everything sent to it, which is fine for a queue of jobs and wrong for independent ones. A small pool sized to navigator.hardwareConcurrency gives real parallelism.

export function createPool(size = Math.max(2, Math.min(4, navigator.hardwareConcurrency ?? 4))) {
  const workers = Array.from({ length: size }, () => createWorkerClient());
  let next = 0;

  return {
    // Round-robin is enough when jobs are similar in size. If they are not,
    // track outstanding jobs per worker and send to the least loaded.
    send: (req: Parameters<typeof workers[0]["send"]>[0], transfer?: Transferable[]) => {
      const worker = workers[next++ % workers.length];
      return worker.send(req, transfer);
    },
    terminate: () => workers.forEach((w) => w.terminate()),
  };
}

Two things about cancellation that catch people. A worker cannot be interrupted from outside: a tight synchronous loop ignores every message you send it, because the message sits in its queue until the loop yields. So long jobs must check a cancellation flag periodically, as the chunking loop above does, and that means breaking the work into pieces that yield.

The nuclear option is worker.terminate(), which stops it immediately and irrecoverably — no cleanup, no final message, and any in-flight promises are left unresolved unless you reject them yourself. It is the right tool when the user navigates away and the wrong one when you intend to reuse the worker, because starting a replacement costs the module load again.

Workers in a server-rendered app

Worker does not exist on the server, so the naive version — a module-level const worker = new Worker(...) imported by a component — throws Worker is not defined during server rendering, before any of your code runs. In the App Router that error appears at build time and names the module rather than the component, which makes it briefly baffling.

The fix is to create the worker lazily, inside an effect, which only runs in the browser. Creating it in a ref rather than in state avoids a render on creation, and the cleanup terminates it so a fast-refresh cycle in development does not accumulate threads.

"use client";
import { useEffect, useRef, useState } from "react";
import type { createWorkerClient } from "./client";

type Client = ReturnType<typeof createWorkerClient>;

export function useWorkerClient() {
  const ref = useRef<Client | null>(null);
  const [ready, setReady] = useState(false);

  useEffect(() => {
    let cancelled = false;

    // Dynamic import, so neither the worker module nor its dependencies are
    // pulled into the server bundle.
    import("./client").then(({ createWorkerClient }) => {
      if (cancelled) return;
      ref.current = createWorkerClient();
      setReady(true);
    });

    return () => {
      cancelled = true;
      ref.current?.terminate();     // fast refresh would otherwise leak threads
      ref.current = null;
    };
  }, []);

  return { client: ref, ready };
}

Three further things about the build, which differ per bundler and are worth checking rather than assuming. The new Worker(new URL("./worker.ts", import.meta.url)) form is the one every modern bundler recognises as a worker entry point — a plain string path is not statically analysable, so the worker file is never emitted and you get a 404 at runtime instead of a compile error. { type: "module" } lets the worker use import, which is what makes sharing the protocol file between the two threads possible at all. And in development the worker is a separate context, so it does not hot-reload with the page: after editing worker.ts, a full refresh is often required, and a confusing bug where your change “has no effect” is usually this rather than your change.

One deployment detail that only appears in production: the worker is a separate JavaScript file fetched at runtime, so it must be served from the same origin with a JavaScript content type. A CDN configuration that serves unknown extensions as text/plain breaks module workers specifically, and the browser’s error mentions MIME type rather than your code.

What not to move

  • Network requests. fetch is already asynchronous and costs the main thread nothing while it waits. Putting a model call in a worker adds a message hop and buys nothing. The exception is parsing a very large response, which is synchronous — move the parse, not the fetch.
  • Anything touching the DOM. Workers have no document, no window, no localStorage. They do have fetch, IndexedDB, WebSocket, OffscreenCanvas and crypto.subtle, which covers most genuinely heavy work.
  • Work under about 5ms. Worker creation is tens of milliseconds and each round trip is a fraction of a millisecond plus the clone. Below that threshold the overhead is the cost.
  • Anything you have not measured. A worker is a real increase in complexity: two build targets, an async protocol, and a debugging story that is worse. Move things because a profile said to.

The strongest case for a worker in an AI application is a model running in the browser, where generation is a long synchronous-ish loop, and client-side document processing — parse, chunk, embed — where every step is over the frame budget. For everything else, the main thread is usually waiting on a network anyway.