Skip to content
Codeloom
JavaScript

JavaScript Web Workers: Run Code Off the Main Thread

Learn how to use JavaScript Web Workers to run CPU-intensive code off the main thread, keeping your UI responsive with practical examples and patterns.

·7 min read · By Codeloom
Intermediate 12 min read

What you'll learn

  • How Web Workers run JavaScript on separate threads
  • Communication patterns between main thread and workers
  • Transferable objects for zero-copy data passing
  • SharedWorker and Worker pool patterns for real applications

Prerequisites

  • JavaScript fundamentals
  • Basic understanding of async programming

The browser’s main thread handles everything: JavaScript execution, DOM updates, layout calculations, painting, and user input. When a heavy computation blocks the main thread, the page freezes. Web Workers solve this by letting you run JavaScript on a separate OS thread, completely parallel to the main thread.

Creating a Basic Worker

A Web Worker runs in its own global scope with no access to the DOM, window, or document. Communication happens through message passing.

// main.js
const worker = new Worker("worker.js");

worker.postMessage({ numbers: [1, 2, 3, 4, 5] });

worker.onmessage = (event) => {
  console.log("Result:", event.data);
};

worker.onerror = (error) => {
  console.error("Worker error:", error.message);
};
// worker.js
self.onmessage = (event) => {
  const { numbers } = event.data;
  const sum = numbers.reduce((a, b) => a + b, 0);
  self.postMessage(sum);
};

The postMessage API serializes data using the structured clone algorithm, which handles most JavaScript types including objects, arrays, Maps, Sets, Dates, RegExps, ArrayBuffers, and even circular references. It does not handle functions, DOM nodes, or Error objects.

Inline Workers with Blob URLs

You can create workers without a separate file using Blob URLs:

function createInlineWorker(fn) {
  const blob = new Blob(
    [`self.onmessage = function(e) { (${fn.toString()})(e); }`],
    { type: "application/javascript" }
  );
  const url = URL.createObjectURL(blob);
  const worker = new Worker(url);

  const originalTerminate = worker.terminate.bind(worker);
  worker.terminate = () => {
    URL.revokeObjectURL(url);
    originalTerminate();
  };

  return worker;
}

const sortWorker = createInlineWorker((event) => {
  const sorted = event.data.slice().sort((a, b) => a - b);
  self.postMessage(sorted);
});

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

Promise-Based Worker Communication

The raw postMessage/onmessage API is callback-based. Wrapping it in Promises makes it much more ergonomic:

function createPromiseWorker(workerUrl) {
  const worker = new Worker(workerUrl);
  const pending = new Map();
  let nextId = 0;

  worker.onmessage = (event) => {
    const { id, result, error } = event.data;
    const promise = pending.get(id);
    if (!promise) return;

    pending.delete(id);

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

  return {
    send(data) {
      const id = nextId++;
      return new Promise((resolve, reject) => {
        pending.set(id, { resolve, reject });
        worker.postMessage({ id, data });
      });
    },

    terminate() {
      worker.terminate();
      for (const { reject } of pending.values()) {
        reject(new Error("Worker terminated"));
      }
      pending.clear();
    },
  };
}
// promise-worker.js
self.onmessage = (event) => {
  const { id, data } = event.data;
  try {
    const result = processData(data);
    self.postMessage({ id, result });
  } catch (err) {
    self.postMessage({ id, error: err.message });
  }
};

Now the main thread can use await:

const worker = createPromiseWorker("promise-worker.js");

const result = await worker.send({ task: "fibonacci", n: 40 });
console.log(result);

Transferable Objects

When you postMessage an ArrayBuffer, the data is cloned by default. For large buffers, this clone can be expensive. Transferable objects move ownership instead of copying:

const buffer = new ArrayBuffer(1024 * 1024 * 100); // 100 MB

// Slow: clones 100 MB
worker.postMessage({ buffer });

// Fast: transfers ownership, zero copy
worker.postMessage({ buffer }, [buffer]);
console.log(buffer.byteLength); // 0 - the buffer is now empty

After transfer, the original buffer becomes detached and unusable on the sending side. This is by design since two threads should not share mutable memory without synchronization.

Types that can be transferred:

  • ArrayBuffer
  • MessagePort
  • ReadableStream
  • WritableStream
  • TransformStream
  • ImageBitmap
  • OffscreenCanvas
async function processImage(imageUrl) {
  const response = await fetch(imageUrl);
  const blob = await response.blob();
  const bitmap = await createImageBitmap(blob);

  worker.postMessage({ bitmap }, [bitmap]);
}

Worker Pool Pattern

Creating a new Worker for every task is expensive. A worker pool reuses a fixed number of workers:

class WorkerPool {
  #workers = [];
  #queue = [];

  constructor(workerUrl, size = navigator.hardwareConcurrency || 4) {
    for (let i = 0; i < size; i++) {
      const worker = new Worker(workerUrl);
      this.#workers.push({ worker, busy: false });
    }
  }

  exec(data) {
    return new Promise((resolve, reject) => {
      const task = { data, resolve, reject };
      const idle = this.#workers.find(w => !w.busy);

      if (idle) {
        this.#runTask(idle, task);
      } else {
        this.#queue.push(task);
      }
    });
  }

  #runTask(entry, task) {
    entry.busy = true;

    entry.worker.onmessage = (event) => {
      task.resolve(event.data);
      this.#completeTask(entry);
    };

    entry.worker.onerror = (error) => {
      task.reject(error);
      this.#completeTask(entry);
    };

    entry.worker.postMessage(task.data);
  }

  #completeTask(entry) {
    entry.busy = false;

    if (this.#queue.length > 0) {
      const nextTask = this.#queue.shift();
      this.#runTask(entry, nextTask);
    }
  }

  terminate() {
    this.#workers.forEach(({ worker }) => worker.terminate());
  }
}

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

const results = await Promise.all([
  pool.exec({ task: "hash", data: chunk1 }),
  pool.exec({ task: "hash", data: chunk2 }),
  pool.exec({ task: "hash", data: chunk3 }),
  pool.exec({ task: "hash", data: chunk4 }),
  pool.exec({ task: "hash", data: chunk5 }),
  pool.exec({ task: "hash", data: chunk6 }),
]);

With 4 workers and 6 tasks, the first 4 tasks run in parallel. As each finishes, the next queued task starts immediately.

SharedWorker

A SharedWorker is shared across all tabs and iframes from the same origin. This is useful for shared state, connection pooling, or cross-tab communication:

// shared-worker.js
const connections = new Set();

self.onconnect = (event) => {
  const port = event.ports[0];
  connections.add(port);

  port.onmessage = (event) => {
    for (const conn of connections) {
      conn.postMessage({
        from: event.data.tabId,
        message: event.data.message,
      });
    }
  };

  port.start();
};
// main.js (same code in every tab)
const shared = new SharedWorker("shared-worker.js");
const tabId = crypto.randomUUID();

shared.port.onmessage = (event) => {
  console.log(`Tab ${event.data.from} says: ${event.data.message}`);
};

shared.port.start();
shared.port.postMessage({ tabId, message: "Hello from new tab" });

Real-World Use Cases

Searching through large datasets without blocking the UI:

// search-worker.js
let dataset = null;

self.onmessage = (event) => {
  if (event.data.type === "init") {
    dataset = event.data.items;
    self.postMessage({ type: "ready" });
    return;
  }

  if (event.data.type === "search") {
    const query = event.data.query.toLowerCase();

    const results = dataset.filter(item =>
      item.title.toLowerCase().includes(query) ||
      item.description.toLowerCase().includes(query)
    );

    self.postMessage({
      type: "results",
      query: event.data.query,
      results: results.slice(0, 50),
    });
  }
};
// main.js
const searchWorker = new Worker("search-worker.js");

searchWorker.postMessage({ type: "init", items: allProducts });

const searchInput = document.getElementById("search");

searchInput.addEventListener("input", (e) => {
  searchWorker.postMessage({
    type: "search",
    query: e.target.value,
  });
});

searchWorker.onmessage = (event) => {
  if (event.data.type === "results") {
    renderResults(event.data.results);
  }
};

CSV Parsing

Parsing large CSV files without freezing the UI:

// csv-worker.js
self.onmessage = (event) => {
  const text = event.data;
  const lines = text.split("\n");
  const headers = lines[0].split(",").map(h => h.trim());
  const rows = [];

  for (let i = 1; i < lines.length; i++) {
    if (!lines[i].trim()) continue;

    const values = lines[i].split(",");
    const row = {};
    headers.forEach((header, index) => {
      row[header] = values[index]?.trim();
    });
    rows.push(row);

    if (i % 10000 === 0) {
      self.postMessage({
        type: "progress",
        percent: Math.round((i / lines.length) * 100),
      });
    }
  }

  self.postMessage({ type: "complete", rows });
};

What Workers Cannot Do

Workers operate in a restricted environment:

  • No DOM access (document, window, DOM APIs)
  • No alert(), confirm(), or prompt()
  • Limited localStorage access (use indexedDB instead)
  • Same-origin restriction on worker scripts

Workers can use:

  • fetch and XMLHttpRequest
  • indexedDB
  • WebSockets
  • setTimeout and setInterval
  • crypto
  • importScripts() for loading additional scripts
  • Other Workers (nested workers)

Wrapping Up

Web Workers let you move CPU-intensive tasks off the main thread, keeping your UI responsive. Use the promise-based wrapper pattern for ergonomic communication, transferable objects for large binary data, and worker pools for parallel task processing. The main thread should handle DOM updates and user interaction, while workers handle computation, parsing, and data processing. Start with a single worker for your heaviest operation and expand to a pool as needed.