Web Workers and Off-Main-Thread Architecture

Comlink, transferable objects, SharedArrayBuffer, and how I moved CSV parsing and search indexing off the main thread on a visual app builder. Measured wins and the gotchas that bit me.

The visual app builder I worked on at the creator economy platform was a React and TypeScript designer creators used to drag together their own branded mobile apps. One Wednesday afternoon, a designer pinged me with a Loom. She’d dragged a 6,000-row CSV into the catalog block. The drop zone accepted the file. Then the canvas froze for 4.2 seconds. Drag handles stopped responding. A modal locked mid-animation. Looked, honestly, like the tab had crashed.

It hadn’t crashed. We were parsing the CSV on the main thread.

That’s the bug that made me serious about Web Workers. Not the hello-world new Worker("worker.js") version. The real one, with a typed RPC layer, transferable objects when they matter, and the discipline to stop doing CPU work on the thread that paints the UI.

Where the main thread breaks

React rerenders, layout, paint, composite, all on the main thread. Your JSON.parse of a 4MB payload, your fuzzy search over 50,000 records, your image resize before upload, by default all of that runs on the same thread. Past ~16ms of synchronous work and you’ve dropped a frame. Past a second and the user starts clicking things twice.

You can’t useMemo your way out of CPU work. Memoization just means you do it less often. When you do it, you still freeze the UI. Workers aren’t an optimization, they’re an architecture choice. You decide this slice of work is a service, the main thread asks, the worker answers.

I’ve written raw postMessage worker code. I don’t anymore. The handler-switch-on-message-type pattern bit-rots within a sprint, types drift, cancellation is ad-hoc. By the second feature you’ve reinvented a worse RPC layer.

Comlink is the boring answer. It wraps postMessage and makes the worker look like an async object. Types flow. Cancellation is just dropping a promise.

// workers/csv.worker.ts
import * as Comlink from "comlink";
import Papa from "papaparse";

export type ParsedRow = Record<string, string>;

export type CsvParseResult =
  | { ok: true; rows: ParsedRow[]; headers: string[]; warnings: string[] }
  | { ok: false; error: string };

const api = {
  async parse(file: File, opts?: { maxRows?: number }): Promise<CsvParseResult> {
    return new Promise((resolve) => {
      const rows: ParsedRow[] = [];
      const warnings: string[] = [];
      let headers: string[] = [];

      Papa.parse<ParsedRow>(file, {
        header: true,
        skipEmptyLines: true,
        worker: false,
        chunk: (chunk) => {
          if (!headers.length && chunk.meta.fields) headers = chunk.meta.fields;
          for (const row of chunk.data) {
            if (opts?.maxRows && rows.length >= opts.maxRows) return;
            rows.push(row);
          }
          for (const err of chunk.errors) warnings.push(err.message);
        },
        complete: () => resolve({ ok: true, rows, headers, warnings }),
        error: (err) => resolve({ ok: false, error: err.message }),
      });
    });
  },
};

export type CsvWorkerApi = typeof api;
Comlink.expose(api);
// hooks/useCsvWorker.ts
import * as Comlink from "comlink";
import { useEffect, useMemo } from "react";
import type { CsvWorkerApi } from "../workers/csv.worker";

export function useCsvWorker() {
  const { worker, api } = useMemo(() => {
    const w = new Worker(new URL("../workers/csv.worker.ts", import.meta.url), {
      type: "module",
    });
    return { worker: w, api: Comlink.wrap<CsvWorkerApi>(w) };
  }, []);

  useEffect(() => () => worker.terminate(), [worker]);

  return api;
}

A React component calls api.parse(file) and awaits a typed result. The main thread doesn’t block. We can stream chunks back through a Comlink proxy callback if we want progress.

The new URL(..., import.meta.url) form is the part that matters for bundling. Vite, Webpack 5, and esbuild all recognize it and emit a separate worker chunk with the right MIME type. Skip that and you’ll get cryptic “failed to construct Worker” errors that only show up in production.

Transferable objects when they earn it

Most of the time postMessage structured-clones your data. For a 6,000-row CSV that’s fine. For anything binary, image resize, Float32Array model weights, audio buffers, video frames, cloning a 50MB Uint8Array undoes most of the win.

Transferables move ownership instead of copying. Gone from the sender, zero-copy on the receiver.

// workers/image.worker.ts
import * as Comlink from "comlink";

const api = {
  async resize(
    rgba: ArrayBuffer,
    w: number,
    h: number,
    targetW: number,
  ): Promise<ArrayBuffer> {
    const src = new Uint8ClampedArray(rgba);
    const targetH = Math.round((h / w) * targetW);
    const out = new Uint8ClampedArray(targetW * targetH * 4);

    // nearest-neighbor for brevity; real code uses bilinear or offscreencanvas
    for (let y = 0; y < targetH; y++) {
      for (let x = 0; x < targetW; x++) {
        const sx = Math.floor((x / targetW) * w);
        const sy = Math.floor((y / targetH) * h);
        const si = (sy * w + sx) * 4;
        const di = (y * targetW + x) * 4;
        out[di] = src[si];
        out[di + 1] = src[si + 1];
        out[di + 2] = src[si + 2];
        out[di + 3] = src[si + 3];
      }
    }
    return Comlink.transfer(out.buffer, [out.buffer]);
  },
};

export type ImageWorkerApi = typeof api;
Comlink.expose(api);

On the main thread you Comlink.transfer(rgba, [rgba]) going in and get a transferred ArrayBuffer back. The 50MB stays as 50MB total in memory, not 100MB. On a designer’s MacBook that’s the difference between a smooth preview and the fans spinning up.

Thing I got wrong the first time. I tried to transfer a plain object that contained an ArrayBuffer field, expecting the buffer inside to move. Comlink doesn’t walk arbitrary objects, you pass the transfer list explicitly. The buffer got structure-cloned and the resize hook ate 80ms it didn’t need to. Now I pass buffers as the top-level value and stash metadata on the side.

SharedArrayBuffer, used carefully

SharedArrayBuffer gives the main thread and the worker a literal pointer to the same memory. No transfer, no copy. With Atomics you get cross-thread sync. Right tool for things like live search indices where the worker writes and the main thread reads constantly.

The catch is the cross-origin isolation headers. Without Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp, the browser pretends SharedArrayBuffer doesn’t exist. We set these only on the editor surface, not on the public marketing pages, because COEP breaks a lot of third-party embeds.

// next.config.ts (editor pages only via middleware-set headers)
export const editorSecurityHeaders = [
  { key: "Cross-Origin-Opener-Policy", value: "same-origin" },
  { key: "Cross-Origin-Embedder-Policy", value: "require-corp" },
];

Used it for a typeahead over a creator’s full product catalog. The worker maintained a tokenized index in a SharedArrayBuffer, the main thread did lock-free reads through Atomics.load. Typeahead latency for a mid-tier creator’s store sat under 30ms. Without sharing, we were paying the postMessage clone on every keystroke and felt it.

I would not reach for SharedArrayBuffer first. It’s a sharp tool. Start with Comlink and structured clone. Move to transferables when payloads get binary and large. Only reach for shared memory when you have measured, repeated reads you cannot batch.

The war story I keep retelling

Not from the app builder. At the live-video creator platform I led engineering at, I’d introduced an atomic CSS system and a shared design system over the previous quarter. A Friday afternoon, five PRs went out in a batch migrating older Vue components. CI passed. Deploy ran. About 30 minutes later support pinged. Creator profile pages had no bio. A global CSS reset bundled with the new design system was zeroing padding-top on <section> tags. The bio container collapsed to zero height. Twitter started noticing.

A teammate hot-patched the bio container with a padding override. Bio came back. Within an hour, three more reports landed for collapsed sections elsewhere. We rolled the bundle back, scoped the reset to a data-attribute boundary, re-shipped two days later, added a Chromatic visual diff against the top routes.

The reason I’m telling this here. Teams reach for workers as a “make it feel fast” tool when the actual problem is bundle weight, a bad CSS reset, or a render that re-mounts the tree. Workers are for CPU work. If the flame chart doesn’t show long tasks in scripting, you’re solving the wrong problem.

What I measure before moving anything

Performance panel, scripting category, long-task threshold 50ms. If a user interaction produces a long task over a second, worker candidate. If it’s 80ms once and you can’t reproduce it, leave it alone. The cost of a worker is real, you pay it in bundle setup, serialization, mental model.

A small useTask helper for the “is this worth a worker” call.

// hooks/useTask.ts
import { useCallback, useRef } from "react";

export function useTask<T>(task: () => Promise<T>) {
  const inFlight = useRef<AbortController | null>(null);

  return useCallback(async () => {
    inFlight.current?.abort();
    const ctrl = new AbortController();
    inFlight.current = ctrl;
    const started = performance.now();
    try {
      const result = await task();
      const ms = performance.now() - started;
      if (ms > 200 && !ctrl.signal.aborted) {
        // log to internal performance bus, not user-facing
        console.warn("[useTask] slow main-thread task", ms);
      }
      return result;
    } finally {
      if (inFlight.current === ctrl) inFlight.current = null;
    }
  }, [task]);
}

If that warning fires on staging, the function is a worker candidate. If it doesn’t, it isn’t.

Bundling notes that bit me

Dynamic imports inside a worker work, but the worker must be a module worker (type: "module"). Classic workers cannot import. I shipped a classic worker once with a require-style shim, looked fine in dev, exploded on the first call in production. Module workers, always.

The worker file is part of your bundle budget. A worker shipping PapaParse plus a tokenizer plus a fuzzy-search lib is easily 200KB gzipped. Lazy-load the worker construction behind the interaction that needs it.

Takeaways

  • Workers are an architecture choice, not an optimization. Pick the slice of work that is a service, then make it one.
  • Comlink over raw postMessage for almost everything. Types flow, cancellation is just dropping the promise.
  • Structured clone is fine until your payloads turn binary. Then use transferables. Pass buffers at the top level, not nested inside objects.
  • SharedArrayBuffer only when you have measured, repeated reads. Mind the COOP and COEP headers.
  • Measure before you move. Long tasks over a second on user interactions are worker candidates. 80ms blips are not.
  • Bundle the worker the modern way, with new Worker(new URL("...", import.meta.url), { type: "module" }). Lazy-load the worker setup itself.

Thanks for reading. If you’ve got thoughts, send them my way.

© 2026 Akin Gundogdu. All Rights Reserved.