JavaScript

JavaScript Web Workers: Keep the UI Smooth While Running Heavy Code

W
W3Tweaks Team
Frontend Tutorials
Jun 7, 2026 23 min read
JavaScript Web Workers: Keep the UI Smooth While Running Heavy Code
Web Worker tutorials always need a separate worker.js file and a build tool. This one doesn't. Learn inline workers with Blob URLs (no separate file), a worker pool you build from scratch, transferable objects benchmark showing 100-300× speed difference, Comlink for ergonomic worker APIs, OffscreenCanvas for jank-free animations, SharedArrayBuffer + Atomics with the COOP/COEP headers you need, the structured clone gotchas, modern Vite/React syntax, and the exact threshold when workers hurt instead of help.

Every Web Worker tutorial starts with “create a file called worker.js”. That means you need a server, a build tool, or at minimum two files open at the same time. It is an unnecessary barrier that stops most developers from ever trying workers in the first place.

A JavaScript worker thread runs on its own event loop, isolated from the DOM. This tutorial starts differently: you can run every example by pasting it into a single HTML file and opening it in Chrome. Inline workers use a Blob URL to create a worker from a string of code — no separate file, no server, no build step.

From there: a worker pool with a real task queue, a live benchmark that proves transferable objects are 100–300× faster than copying for large data, Comlink for ergonomic worker APIs, OffscreenCanvas to render canvases off the main thread, SharedArrayBuffer + Atomics with the COOP/COEP headers you need, the structured clone gotchas that cause DataCloneError, modern Vite/React syntax for production projects, and the concrete numbers for when workers cost more than they save.

If you want to understand why the main thread freezes in the first place, the Event Loop Explained tutorial covers the underlying mechanics. For long-running work like caches, debounce timers, and async cleanup that also affect UI smoothness, see Debounce vs Throttle and Memory Leak Fix.

Live Demo

Live Demo Open in tab

Tab 1: bouncing ball shows UI freeze vs smooth. Tab 2: worker pool running 8 tasks across 4 workers. Tab 3: transferable objects benchmark — copy vs transfer timing. Tab 4: progress reporting from worker to main thread.

Web Worker vs Main Thread — Why the UI Freezes

Choosing web worker vs main thread comes down to one number: does the task exceed 10 ms? JavaScript has one main thread. That thread handles:

  • DOM updates and rendering
  • User events (clicks, typing, scroll)
  • Your JavaScript code

When your code runs a heavy computation, it monopolises the main thread. The browser cannot render, cannot process clicks, cannot scroll. The page freezes for the duration of the computation.

// ❌ This freezes everything for ~2 seconds
function heavySort(data) {
  return data.sort((a, b) => {
    return JSON.stringify(b).localeCompare(JSON.stringify(a));
  });
}

button.addEventListener('click', () => {
  const result = heavySort(millionItems); // 2s freeze
  renderTable(result);
});

The single-thread model is not a bug — it makes DOM manipulation safe and predictable. Web Workers are the escape hatch: run heavy code on a separate thread that cannot touch the DOM but can do everything else.

Step 1 — JavaScript Web Worker Example: The Inline Worker

The simplest javascript web worker example is an inline Blob URL — no separate file needed. The standard approach requires a worker.js file:

const worker = new Worker('worker.js'); // needs a file on a server

Web Worker Without Separate File — The Blob URL Approach

You can spin up a web worker without a separate file by wrapping the script in a Blob URL:

// Create worker code as a string
const workerCode = `
  self.onmessage = function(event) {
    const { data, id } = event.data;

    // Heavy work happens here — off the main thread
    let result = 0;
    for (let i = 0; i < data.length; i++) {
      result += Math.sqrt(data[i]);
    }

    self.postMessage({ id, result });
  };
`;

// Convert the string to a Blob, create a URL for it
const blob   = new Blob([workerCode], { type: 'application/javascript' });
const blobUrl = URL.createObjectURL(blob);

// Create worker from the URL
const worker = new Worker(blobUrl);

// Clean up the URL when done (optional but good practice)
URL.revokeObjectURL(blobUrl);

worker.postMessage({ id: 1, data: [1, 4, 9, 16, 25] });
worker.onmessage = (event) => console.log('Result:', event.data.result);

A reusable inline worker factory:

function createInlineWorker(fn) {
  const code = `self.onmessage = function(e) {
    const result = (${fn.toString()})(e.data);
    self.postMessage(result);
  }`;

  const blob   = new Blob([code], { type: 'application/javascript' });
  const url    = URL.createObjectURL(blob);
  const worker = new Worker(url);
  URL.revokeObjectURL(url);
  return worker;
}

// Usage — pass any pure function
const sortWorker = createInlineWorker(function(data) {
  return data.sort((a, b) => a - b);
});

sortWorker.postMessage([5, 3, 8, 1, 9, 2]);
sortWorker.onmessage = (e) => console.log('Sorted:', e.data);

The limitation: The worker function must be pure — it cannot reference variables from the outer scope (closures do not cross thread boundaries). Everything the worker needs must be sent via postMessage.

Step 2 — Web Worker postMessage Example

Workers communicate with the main thread through message passing. Data is structured-cloned (deep-copied) in both directions by default. Below is a minimal web worker postMessage example showing both sides of the channel:

// ── Main thread ──────────────────────────────────────────────
const worker = new Worker('worker.js');

worker.postMessage({
  type:    'process',
  payload: { items: largeArray, config: { threshold: 0.5 } }
});

worker.onmessage = function(event) {
  const { type, result, error } = event.data;

  if (type === 'result') renderChart(result);
  if (type === 'progress') updateProgressBar(event.data.pct);
  if (type === 'error') showErrorMessage(error);
};

worker.onerror = function(event) {
  console.error('Worker error:', event.message, 'at', event.filename, ':', event.lineno);
  event.preventDefault();
};

// onmessageerror — fires when structured clone of an INCOMING message
// fails. Different from onerror (which catches uncaught exceptions in
// the worker). Common cause: receiving something with a function or DOM
// node in it.
worker.onmessageerror = function(event) {
  console.error('Message could not be cloned:', event);
};

worker.terminate();
// ── worker.js ────────────────────────────────────────────────
self.onmessage = function(event) {
  const { type, payload } = event.data;

  try {
    if (type === 'process') {
      for (let i = 0; i < payload.items.length; i++) {
        processItem(payload.items[i], payload.config);

        if (i % 1000 === 0) {
          self.postMessage({
            type: 'progress',
            pct:  Math.round((i / payload.items.length) * 100)
          });
        }
      }

      self.postMessage({ type: 'result', result: computedResult });
    }
  } catch (err) {
    self.postMessage({
      type:    'error',
      error:   { message: err.message, stack: err.stack }
    });
  }
};

Structured Clone — What Survives, What Throws DataCloneError

postMessage uses the structured clone algorithm under the hood. Not everything can cross the thread boundary:

CloneableNOT cloneable (throws DataCloneError)
Date, RegExp, Map, SetFunction (any kind)
ArrayBuffer, all TypedArraysError (only message + stack preserved)
Blob, File, FileList, ImageDataDOM Node (Element, Document, Window)
Plain objects + arrays (recursively)Symbol
Primitives (string, number, bigint, boolean, null)WeakMap, WeakSet, WeakRef
Self-referencing structuresClass instances lose their prototype (become plain objects)

If you need to send “behaviour” to a worker, send data the worker interprets as instructions (a discriminated union of message types), or use Comlink below which handles the function-passing problem cleanly.

Step 3 — Transferable Objects Web Worker: The 100-300× Speed Difference

By default, postMessage copies data. For a 64MB ArrayBuffer, copying via structured clone takes ~100-250ms — defeating the purpose of using a worker.

Transferable objects move ownership of the buffer to the receiving thread in ~1ms. The original reference becomes empty (zero-copy transfer, no serialisation). Real measured ratios on modern hardware land in the 100-300× range depending on buffer size and CPU memory bandwidth:

// ──────────────────────────────────────────────────────────────
// COPY (default): 64MB takes ~100-250ms to serialize and deserialize
// ──────────────────────────────────────────────────────────────
const buffer = new ArrayBuffer(64 * 1024 * 1024); // 64MB
const t0     = performance.now();
worker.postMessage({ buffer });                    // COPIES buffer
console.log('Copy time:', performance.now() - t0, 'ms');
// buffer is still usable on main thread

// ──────────────────────────────────────────────────────────────
// TRANSFER (zero-copy): same 64MB takes ~1ms
// ──────────────────────────────────────────────────────────────
const buffer = new ArrayBuffer(64 * 1024 * 1024); // 64MB
const t0     = performance.now();
worker.postMessage({ buffer }, [buffer]);          // TRANSFERS buffer
console.log('Transfer time:', performance.now() - t0, 'ms');
// buffer is NOW EMPTY on main thread — ownership moved to worker
console.log(buffer.byteLength); // 0 — transferred!

Transferable objects in a web worker hand off the buffer instead of copying it — hence the large speed gap. The second argument to postMessage is the transferables list — an array of objects to transfer instead of copy. Supported transferables:

  • ArrayBuffer (and typed arrays: Uint8Array, Float32Array, etc.)
  • MessagePort
  • OffscreenCanvas (see dedicated section below)
  • ReadableStream, WritableStream, TransformStream
  • ImageBitmap
// Practical: transfer image pixel data for processing
const canvas  = document.getElementById('myCanvas');
const ctx     = canvas.getContext('2d');
const pixels  = ctx.getImageData(0, 0, canvas.width, canvas.height);
const buffer  = pixels.data.buffer; // ArrayBuffer

worker.postMessage(
  { pixels: buffer, width: canvas.width, height: canvas.height },
  [buffer] // ← transfer, not copy
);

worker.onmessage = (e) => {
  const processed = new ImageData(
    new Uint8ClampedArray(e.data.buffer),
    canvas.width
  );
  ctx.putImageData(processed, 0, 0);
};

SharedArrayBuffer Web Worker Example — True Shared Memory

For true shared memory (instead of ownership transfer), use SharedArrayBuffer + Atomics. A SharedArrayBuffer web worker example needs COOP/COEP headers first. Without them, the constructor is undefined:

<!-- Required server-side headers -->
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
// Feature-detect cross-origin isolation
if (!crossOriginIsolated) {
  console.error('SharedArrayBuffer requires cross-origin isolation');
  // Fall back to ArrayBuffer + transfer
}

// 4 bytes — one Int32 used as a shared counter
const sab = new SharedArrayBuffer(4);
const counter = new Int32Array(sab);

// Send to worker — NO transferables list, it's already shared
worker.postMessage({ counter: sab });

// Main thread can atomically increment + read
Atomics.add(counter, 0, 1);
console.log(Atomics.load(counter, 0)); // also visible in worker

// In the worker:
self.onmessage = ({ data }) => {
  const sharedCounter = new Int32Array(data.counter);
  // Wake up only when main thread changes the value (no busy-loop!)
  Atomics.wait(sharedCounter, 0, 0);
  console.log('Main thread incremented to:', Atomics.load(sharedCounter, 0));
};

Atomics.wait and Atomics.notify give you busy-loop-free coordination between threads — essential for any non-trivial shared-state pattern. Use SharedArrayBuffer only when ownership transfer doesn’t work for your case (e.g., both threads need concurrent read access).

Step 4 — Web Worker Pool JavaScript: Build From Scratch

A single worker processes tasks one at a time. A worker pool maintains N workers and distributes tasks across them, enabling true parallel processing on multi-core CPUs.

Building a web worker pool in JavaScript takes about 40 lines once you have a task queue. Every tutorial mentions worker pools — nobody actually builds one:

class WorkerPool {
  constructor(workerScript, size = navigator.hardwareConcurrency || 4) {
    this.size    = size;
    this.workers = [];
    this.queue   = [];      // pending tasks
    this.idle    = [];      // available worker indices

    for (let i = 0; i < size; i++) {
      const worker = new Worker(workerScript);

      worker.onmessage = (event) => {
        const { resolve, reject } = this.tasks.get(event.data.taskId);

        if (event.data.error) {
          reject(new Error(event.data.error));
        } else {
          resolve(event.data.result);
        }

        this.tasks.delete(event.data.taskId);
        this._releaseWorker(i);
      };

      worker.onerror = (event) => {
        const pending = this.tasks.get(event.data?.taskId);
        if (pending) pending.reject(new Error(event.message));
        this._releaseWorker(i);
      };

      this.workers.push(worker);
      this.idle.push(i);
    }

    this.tasks  = new Map();
    this.taskId = 0;
  }

  run(data, transferables = []) {
    return new Promise((resolve, reject) => {
      const taskId = this.taskId++;
      this.tasks.set(taskId, { resolve, reject });

      if (this.idle.length > 0) {
        this._dispatch(this.idle.pop(), taskId, data, transferables);
      } else {
        this.queue.push({ taskId, data, transferables });
      }
    });
  }

  _dispatch(workerIndex, taskId, data, transferables) {
    this.workers[workerIndex].postMessage({ taskId, data }, transferables);
  }

  _releaseWorker(workerIndex) {
    if (this.queue.length > 0) {
      const { taskId, data, transferables } = this.queue.shift();
      this._dispatch(workerIndex, taskId, data, transferables);
    } else {
      this.idle.push(workerIndex);
    }
  }

  terminate() {
    this.workers.forEach(w => w.terminate());
    this.workers = [];
    this.idle    = [];
    this.queue   = [];
  }
}

Usage — process 16 datasets in parallel across 4 workers:

const pool = new WorkerPool('compute-worker.js', 4);

const results = await Promise.all(
  datasets.map(data => pool.run(data))
);

pool.terminate();

navigator.hardwareConcurrency returns the number of logical CPU cores — typically 4–16 on modern machines. Use this as the default pool size to match the hardware.

If you don’t want to write postMessage plumbing for every method, Comlink (Google, 1.1KB gzipped) wraps a Worker in an ES6 Proxy so you call worker functions like async functions on the main thread. Here’s a Comlink web worker example that turns postMessage into a Proxy-backed async call:

// ── worker.js ──
import * as Comlink from 'https://unpkg.com/comlink/dist/esm/comlink.mjs';

const api = {
  async heavyJob(payload) {
    let result = 0;
    for (let i = 0; i < payload.length; i++) {
      result += Math.sqrt(payload[i]);
    }
    return result;
  },

  async parseCSV(csvText) {
    return csvText.split('\n').map(row => row.split(','));
  },

  counter: 0,
  incrementCounter() { this.counter++; return this.counter; },
};

Comlink.expose(api);
// ── main.js ──
import * as Comlink from 'https://unpkg.com/comlink/dist/esm/comlink.mjs';

const worker = new Worker('worker.js', { type: 'module' });
const api    = Comlink.wrap(worker);

// Looks synchronous — actually crosses thread boundary
const sum = await api.heavyJob([1, 2, 3, 4, 5]);
const rows = await api.parseCSV(csvString);
const count = await api.incrementCounter();

No message-passing boilerplate, full TypeScript inference if you type the api object, and the same async/await ergonomics as any HTTP call. For production apps with more than 2-3 worker methods, Comlink is the recommended pattern.

If you don’t want to ship a dependency, the same idea hand-rolled:

function createWorkerProxy(worker) {
  let msgId = 0;
  const pending = new Map();

  worker.onmessage = ({ data }) => {
    const { id, result, error } = data;
    const { resolve, reject } = pending.get(id);
    pending.delete(id);
    if (error) reject(new Error(error));
    else resolve(result);
  };

  return new Proxy({}, {
    get: (_, method) => (...args) => new Promise((resolve, reject) => {
      const id = msgId++;
      pending.set(id, { resolve, reject });
      worker.postMessage({ id, method, args });
    }),
  });
}

// Usage
const api = createWorkerProxy(myWorker);
const result = await api.heavyJob(payload);

In the worker, dispatch by method name from event.data.method. ~20 lines, no dependency, ~80% of Comlink’s ergonomics.

Step 6 — OffscreenCanvas Web Worker

OffscreenCanvas in a web worker keeps Three.js animations smooth when the main thread is busy. The standard <canvas> only renders on the main thread — when the main thread freezes, canvas animation stops. OffscreenCanvas transfers control of the canvas to a worker that can drive requestAnimationFrame independently:

<canvas id="three-scene" width="800" height="600"></canvas>
// ── main.js ──
const canvas = document.getElementById('three-scene');
const offscreen = canvas.transferControlToOffscreen();

const worker = new Worker('render-worker.js', { type: 'module' });
worker.postMessage({ canvas: offscreen }, [offscreen]); // TRANSFER the canvas
// ── render-worker.js ──
self.onmessage = ({ data }) => {
  const canvas = data.canvas;
  const ctx = canvas.getContext('2d'); // or 'webgl' / 'webgl2' / 'webgpu'

  let frame = 0;
  function render() {
    ctx.fillStyle = `hsl(${frame % 360}, 70%, 50%)`;
    ctx.fillRect(0, 0, canvas.width, canvas.height);
    frame++;
    requestAnimationFrame(render); // ← workers have rAF too
  }
  render();
};

Works with WebGL, WebGPU, and Three.js — the Three.js OffscreenCanvas example shows a full scene running entirely off the main thread. Browser support: Chrome 69+, Firefox 105+, Safari 17+. Universally Baseline as of late 2025.

Step 7 — Module Workers + Bundler Syntax for Vite / React

Workers can use ES module syntax — import/export — by adding { type: 'module' }:

const worker = new Worker('worker.js', { type: 'module' });
// worker.js — can now use import
import { parseCSV }     from './utils/csv.js';
import { computeStats } from './utils/stats.js';

self.onmessage = async ({ data }) => {
  const rows  = parseCSV(data.csv);
  const stats = computeStats(rows);
  self.postMessage(stats);
};

How to Use Web Worker in React / Vite

For production projects with a bundler, how to use a web worker in React with Vite:

// The canonical Vite + modern bundler syntax
const worker = new Worker(
  new URL('./worker.ts', import.meta.url),
  { type: 'module' }
);

// OR: Vite's ?worker suffix for an ergonomic factory
import MyWorker from './worker.ts?worker';
const worker = new MyWorker();
// vite.config.ts
export default {
  worker: {
    format: 'es', // emit module workers (default in Vite 5+)
  },
};

For React, the react-use-worker and comlink-loader packages wrap the boilerplate further — but the raw new Worker(new URL(...)) pattern works with any framework. The Node.js counterpart is worker_threads: same conceptual model, different module name (import { Worker } from 'node:worker_threads').

Service Worker vs Web Worker (and Shared Worker)

Service worker vs web worker: same threading model, opposite job descriptions. Constantly confused. Here’s the breakdown including SharedWorker:

Dedicated WorkerShared WorkerService Worker
PurposeHeavy compute off the main threadState shared across tabs from same originNetwork intercept, offline cache, push
LifespanLives until page closes or terminate()Lives until last tab closesPersists after pages close; lifetime managed by browser
Created withnew Worker(url)new SharedWorker(url)navigator.serviceWorker.register(url)
Cross-tab❌ No✅ Yes✅ Yes
Network intercept❌ No❌ Nofetch event
Push notifications❌ No❌ No✅ Yes
DOM access❌ No❌ No❌ No
Typical useCSV parsing, image filters, large sortsSingle WebSocket shared across tabs, in-tab state syncPWA offline, asset caching, push notifications

This article is about Dedicated Workers. For Service Workers, the lifecycle, event model, and caching strategy are different enough that they need a dedicated tutorial. SharedWorker is genuinely useful for “all tabs from this origin share one WebSocket connection” — a real production pattern — but otherwise rare.

Partytown — Third-Party Scripts in a Worker

Partytown (by Builder.io, used by Astro, Next.js, Qwik, and Shopify Hydrogen) takes Google Analytics, Google Tag Manager, Meta Pixel, and other heavyweight third-party scripts and runs them inside a Web Worker. The page’s main thread stops being blocked by analytics tag JavaScript.

<!-- Tell Partytown which scripts to offload -->
<script type="text/partytown">
  // GA4 init code — now runs in worker
  window.dataLayer = window.dataLayer || [];
  function gtag(){ dataLayer.push(arguments); }
  gtag('config', 'G-XXXX');
</script>

The one caveat: Partytown proxies DOM access from the worker to the main thread via a synchronous-XHR trick, which makes each DOM call from the worker noticeably slower than it would be on the main thread. For analytics tags that fire-and-forget, this is fine. For interactive third-party widgets, it’s a tradeoff.

Progress Reporting From Worker

Long-running tasks should report progress back. Workers can call self.postMessage as many times as they want:

// worker.js — reports progress every 5%
self.onmessage = function({ data: { items, taskId } }) {
  const total   = items.length;
  const results = [];

  for (let i = 0; i < total; i++) {
    results.push(heavyProcess(items[i]));

    const pct = Math.floor((i + 1) / total * 100);
    if (pct % 5 === 0) {
      self.postMessage({ type: 'progress', taskId, pct });
    }
  }

  self.postMessage({ type: 'done', taskId, results });
};

// main.js
worker.onmessage = ({ data }) => {
  if (data.type === 'progress') {
    progressBar.style.width = data.pct + '%';
    progressLabel.textContent = data.pct + '%';
  }
  if (data.type === 'done') {
    renderResults(data.results);
    worker.terminate();
  }
};

When NOT to Use Web Workers

This is the section nobody writes. Workers have real overhead — for small tasks, using a worker is slower than doing the work on the main thread.

The overhead comes from:

  1. Worker creation: ~5–50ms (one-time, mitigated by reusing workers)
  2. postMessage serialisation: proportional to data size
  3. Context switching: ~0.1–1ms per message

The RAIL Budget Threshold (Surma’s Numbers)

Surma’s RAIL-aligned analysis gives concrete budgets for the 100ms response budget (RAIL’s interaction target):

  • postMessage payloads ≤ 100 KiB stay within the 100ms response budget
  • postMessage payloads ≤ 10 KiB are safe for animation frames (16ms budget)
  • Above 100 KiB — use transferable objects to skip the structured clone cost

Combined with the “task must take more than the overhead” rule:

function shouldUseWorker(estimatedComputeMs, payloadKiB) {
  // Compute cost must exceed worker overhead
  if (estimatedComputeMs < 10) return false;

  // Payload above 100 KiB needs transferables to fit RAIL response budget
  if (payloadKiB > 100 && !canUseTransferables) return false;

  return true;
}

// Practical thresholds for common operations:
// JSON.parse:    > 500KB   (~10ms on a typical machine)
// Array sort:    > 50,000 items
// Text search:   > 100,000 records
// Image filter:  any canvas > 512×512px

Never use workers for:

  • Operations that take < 5ms
  • DOM manipulation (workers have no DOM access)
  • Operations where you need the result before the next paint (use rAF instead)
  • Very small, frequent messages (overhead accumulates)

Real-World Use Cases

TaskWorker?Why
JSON.parse of API response > 500KB✅ Yes~20–200ms blocking
CSV parsing with 10K+ rows✅ YesString processing is CPU-heavy
Image filter / pixel manipulation✅ YesEach pixel = one operation
Three.js / WebGL animation✅ OffscreenCanvasRenders independently of main thread
Full-text search across large dataset✅ Yes100K records × regex = expensive
Sorting 1M numbers✅ Yes50–500ms depending on algorithm
Cryptographic hash (bcrypt, SHA-256)✅ YesDeliberately CPU-intensive
Google Analytics / GTM third-party scripts✅ PartytownRemoves blocking from page load
Fibonacci(40)⚠ Maybe~800ms, but better algorithm fixes it
JSON.parse of < 100KB response❌ No< 5ms — overhead exceeds benefit
Sorting 100 items❌ NoMicroseconds
DOM animation❌ NoWorkers cannot touch the DOM

Key Takeaways

  • Inline workers with Blob URLs remove the “separate file” barrier — create workers from a string of code in any environment
  • Workers communicate via postMessage — data is deep-copied via the structured clone algorithm by default
  • Functions, DOM nodes, Symbols, and class-instance prototypes don’t survive postMessage — sending them throws DataCloneError. Use onmessageerror to catch clone failures separately from onerror
  • Transferable objects (ArrayBuffer, ImageBitmap, OffscreenCanvas) transfer ownership instead of copying — 100-300× faster for large data, but the original reference becomes empty
  • SharedArrayBuffer + Atomics gives true shared memory; requires COOP/COEP headers and crossOriginIsolated feature detection
  • A worker pool processes multiple tasks in parallel across N workers with a queue; use navigator.hardwareConcurrency as the default pool size
  • Comlink (Google’s 1.1KB library) wraps a Worker in an ES6 Proxy — call worker methods like async functions, no postMessage boilerplate
  • OffscreenCanvas transfers a canvas to a worker for jank-free WebGL/Three.js animations — Baseline late 2025
  • Module workers ({ type: 'module' }) enable import/export; in Vite use new Worker(new URL('./w.ts', import.meta.url), { type: 'module' }) or the ?worker suffix
  • Service Worker vs Web Worker: same threading model, opposite jobs — workers for compute, service workers for network/offline
  • Partytown runs third-party analytics scripts (GA, GTM, Pixel) in a worker to unblock the main thread
  • Surma’s RAIL budgets: postMessage payloads ≤ 100 KiB fit the 100ms response target, ≤ 10 KiB fit the 16ms animation frame
  • The serialisation overhead makes workers counterproductive for tasks under ~10ms
  • Always call worker.terminate() when done — pending promises on a terminated worker never resolve, so wrap them with a timeout

FAQ

Do Web Workers have access to fetch, localStorage, or console?

Workers have access to fetch, console.log, setTimeout/setInterval, WebSocket, IndexedDB, URL, crypto, performance, and most non-DOM Web APIs. They do not have access to localStorage or sessionStorage (synchronous, would block — use IndexedDB instead), the document or window objects, or any DOM APIs. Workers run in a DedicatedWorkerGlobalScope instead of window.

What is the difference between a Dedicated Worker, Shared Worker, and Service Worker?

A Dedicated Worker is owned by one page and terminates when the page closes — covers 99% of CPU-bound use cases. A Shared Worker can be accessed by multiple pages from the same origin — useful for sharing state between tabs (e.g., one WebSocket connection shared across tabs). A Service Worker intercepts network requests for offline caching and push notifications — completely different lifecycle, persists after the page closes, not used for CPU-bound computation.

How do I debug a Web Worker?

In Chrome DevTools, open the Sources panel and look for the Threads section in the sidebar. Your worker appears there by name. You can set breakpoints, step through code, and use console.log (messages appear in the main console) exactly as you would on the main thread. Use debugger statements inside worker code to pause execution.

Why is my web worker not working?

90% of the time it’s a DataCloneError or a missing type: 'module'. Open DevTools — the error usually says exactly which value couldn’t be cloned. Other common causes: (1) the worker file path is wrong (404 = silent failure with new Worker(url)), (2) you’re using import in the worker without { type: 'module' } on construction, (3) you’re in a sandboxed iframe without allow-scripts, (4) your inline Blob URL was created before the Worker constructor ran but you revoked it too early — keep the URL alive until the Worker is created.

Can I use a Web Worker in React?

Yes. The cleanest pattern in Vite-based React projects is new Worker(new URL('./worker.ts', import.meta.url), { type: 'module' }) or the Vite ?worker suffix: import MyWorker from './worker.ts?worker'. For more ergonomics, comlink + react-use-worker wrap the boilerplate. Create the worker once with useRef (or a module-level singleton), not on every render — recreating the worker on every render is a memory leak. See Memory Leak Fix for the cleanup pattern.

Can a worker create another worker?

Yes — workers can create their own child workers (subworkers) using new Worker() from inside a worker context. This enables hierarchical parallelism: a parent worker spawns multiple child workers for sub-tasks. Support is universal in modern browsers. Child workers terminate when their parent worker terminates.

What does terminating a web worker do to in-flight promises?

worker.terminate() kills the worker instantly — pending promises that wait on postMessage responses never resolve or reject, so they leak forever unless wrapped with a timeout. Pattern: Promise.race([workerCall, new Promise((_, rej) => setTimeout(() => rej(new Error('timeout')), 10000))]). For graceful shutdown, send a “shutdown” message and let the worker finish its queue, then terminate().

Is there a simpler API than raw postMessage?

Yes — Comlink (Google, ~1.1KB) wraps Web Workers in a Proxy that lets you call worker functions as if they were async functions on the main thread, completely hiding the message-passing boilerplate. For inline workers created with Blob URLs without a dependency, the 20-line createWorkerProxy pattern shown above gives you most of Comlink’s ergonomics in pure code.

What happens if I pass a function to postMessage?

Functions cannot be transferred between threads — the structured clone algorithm throws a DataCloneError. If you need to send behaviour to a worker, either send data that the worker interprets as instructions (discriminated union of message types), or use Comlink which handles function serialisation via the Proxy. For inline workers created with Blob URLs, you write the function as part of the worker code string rather than passing it at runtime.

How do I share memory between the main thread and a worker?

Use SharedArrayBuffer + Atomics. Requires the page to be cross-origin isolated via Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp headers — feature-detect with if (crossOriginIsolated). Use Atomics.add for atomic mutation, Atomics.load/store for reads/writes, and Atomics.wait/notify for busy-loop-free coordination between threads.