JavaScript Interview Prep
Performance & Optimization

Web Workers

Move the Heavy Lifting Off the Main Thread

LinkedIn Hook

Your user clicks "Apply Filter" on a 4K image. The tab freezes. Scroll locks. Clicks pile up. Chrome shows "Page Unresponsive".

All because one for loop is hogging the main thread.

JavaScript is single-threaded by default — but the browser gives you a real second thread for free: the Web Worker. Spin one up, post a message with the pixel buffer, and the UI stays at 60fps while a CPU core 300ms away crunches numbers.

In this lesson: basic Worker messaging, zero-copy Transferable Objects (the difference between postMessage copying 80MB and transferring 80MB in a microsecond), a real image-filter example, SharedWorker for cross-tab state, and Comlink to turn postMessage boilerplate into await api.fibonacci(40).

If your app freezes during heavy work — this lesson is the fix.

Read the full lesson -> [link]

#JavaScript #WebWorkers #Concurrency #Performance #InterviewPrep #Multithreading #Frontend


Web Workers thumbnail


What You'll Learn

  • Why long synchronous loops freeze the UI — and how Web Workers give you a real second thread
  • How postMessage / onmessage communication works and when to use Transferable Objects for zero-copy transfer
  • The difference between Worker, SharedWorker, and ServiceWorker, plus a practical Comlink setup

The Restaurant Kitchen Analogy

Imagine a restaurant kitchen. The main chef (main thread) handles everything — taking orders, cooking, plating, serving. If someone orders a complex dish that takes 30 minutes, everything stops. No one else gets served. Web Workers are like hiring a sous chef — they handle the heavy cooking in a separate kitchen while the main chef keeps serving.

The Main Thread Problem

// This blocks the UI for seconds
function heavyComputation() {
  let sum = 0;
  for (let i = 0; i < 1_000_000_000; i++) {
    sum += Math.sqrt(i) * Math.sin(i);
  }
  return sum;
}

// While this runs:
// - Clicks don't register
// - Animations freeze
// - Scroll is locked
// - "Page unresponsive" warning appears
document.getElementById("btn").addEventListener("click", () => {
  const result = heavyComputation(); // UI frozen!
  document.getElementById("result").textContent = result;
});

Basic Web Worker Example: Fibonacci

// main.js — runs on main thread
const worker = new Worker("fib-worker.js");

// Send work to the worker
document.getElementById("calculate").addEventListener("click", () => {
  const n = parseInt(document.getElementById("input").value);
  worker.postMessage({ type: "fibonacci", n });
  document.getElementById("status").textContent = "Calculating...";
  // UI stays responsive! User can scroll, click, type.
});

// Receive result from worker
worker.onmessage = function(event) {
  const { result, duration } = event.data;
  document.getElementById("result").textContent =
    `Result: ${result} (took ${duration}ms)`;
  document.getElementById("status").textContent = "Done!";
};

// Handle errors
worker.onerror = function(error) {
  console.error("Worker error:", error.message);
};
// fib-worker.js — runs in a separate thread
self.onmessage = function(event) {
  const { type, n } = event.data;

  if (type === "fibonacci") {
    const start = performance.now();
    const result = fibonacci(n);
    const duration = (performance.now() - start).toFixed(2);

    self.postMessage({ result, duration });
  }
};

// Recursive fibonacci (intentionally CPU-intensive for demo)
function fibonacci(n) {
  if (n <= 1) return n;
  return fibonacci(n - 1) + fibonacci(n - 2);
}

Transferable Objects (Zero-Copy)

// SLOW — postMessage copies data (structured clone)
const hugeArray = new Float64Array(10_000_000); // ~80MB
worker.postMessage({ data: hugeArray }); // copies 80MB — slow!

// FAST — transfer ownership (zero-copy)
const hugeArray = new Float64Array(10_000_000);
worker.postMessage({ data: hugeArray }, [hugeArray.buffer]);
// hugeArray.byteLength is now 0 — ownership transferred!
// The worker receives it instantly — no copy needed

// In the worker:
self.onmessage = function(event) {
  const { data } = event.data;
  // Process the data...
  const result = processData(data);

  // Transfer it back
  self.postMessage({ result: data }, [data.buffer]);
};

Practical Example: Image Processing in a Worker

// main.js
const canvas = document.getElementById("canvas");
const ctx = canvas.getContext("2d");
const worker = new Worker("image-worker.js");

async function applyFilter(filterType) {
  const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);

  // Transfer the pixel buffer to the worker (zero-copy)
  worker.postMessage(
    { imageData: imageData.data.buffer, width: canvas.width, height: canvas.height, filter: filterType },
    [imageData.data.buffer]
  );
}

worker.onmessage = function(event) {
  const { processedBuffer, width, height } = event.data;
  const processedData = new Uint8ClampedArray(processedBuffer);
  const imageData = new ImageData(processedData, width, height);
  ctx.putImageData(imageData, 0, 0);
};
// image-worker.js
self.onmessage = function(event) {
  const { imageData, width, height, filter } = event.data;
  const pixels = new Uint8ClampedArray(imageData);

  switch (filter) {
    case "grayscale":
      for (let i = 0; i < pixels.length; i += 4) {
        const avg = (pixels[i] + pixels[i+1] + pixels[i+2]) / 3;
        pixels[i] = avg;     // R
        pixels[i+1] = avg;   // G
        pixels[i+2] = avg;   // B
        // pixels[i+3] = alpha (unchanged)
      }
      break;

    case "invert":
      for (let i = 0; i < pixels.length; i += 4) {
        pixels[i] = 255 - pixels[i];
        pixels[i+1] = 255 - pixels[i+1];
        pixels[i+2] = 255 - pixels[i+2];
      }
      break;
  }

  // Transfer back
  self.postMessage(
    { processedBuffer: pixels.buffer, width, height },
    [pixels.buffer]
  );
};

SharedWorker — One Worker, Multiple Tabs

// shared-worker.js
const connections = [];

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

  port.onmessage = function(e) {
    // Broadcast to all connected tabs
    connections.forEach(p => {
      p.postMessage({ from: "shared-worker", data: e.data });
    });
  };

  port.start();
};

// tab.js — each tab connects to the same worker
const shared = new SharedWorker("shared-worker.js");
shared.port.start();

shared.port.postMessage("Hello from this tab!");
shared.port.onmessage = function(event) {
  console.log("Received:", event.data);
};
// Workers CANNOT:
// - Access the DOM (no document, no window)
// - Access the parent's scope directly
// - Use synchronous XHR (on main thread it's deprecated anyway)

// Workers CAN:
// - Use fetch, WebSocket, IndexedDB
// - Use setTimeout, setInterval
// - Import scripts via importScripts() or ES modules
// - Create sub-workers

// Comlink — makes workers feel like async functions
// npm install comlink

// worker.js (with Comlink)
import * as Comlink from "comlink";

const api = {
  fibonacci(n) {
    if (n <= 1) return n;
    return this.fibonacci(n - 1) + this.fibonacci(n - 2);
  },

  async processData(data) {
    // heavy work...
    return transformedData;
  }
};

Comlink.expose(api);

// main.js (with Comlink)
import * as Comlink from "comlink";

const api = Comlink.wrap(new Worker("worker.js"));

// Use it like a normal async function!
const result = await api.fibonacci(40);
console.log(result); // no postMessage boilerplate

Web Workers visual 1


Common Mistakes

  • Passing a huge typed array to a worker without transferring the buffer — postMessage(data) uses structured clone and copies every byte, so a 50MB array means a 50MB memcpy on each send. Always pass the buffer in the transfer list: postMessage(data, [data.buffer]).
  • Trying to touch the DOM from inside a worker — document, window, and localStorage are not defined in a worker. Do the DOM work on the main thread; use the worker only for pure computation.
  • Spawning one worker per click and never terminating them — each worker is a real OS-level thread with its own JS context. Keep a worker pool and reuse them, or call worker.terminate() when you're done.

Interview Questions

Q: What is a Web Worker and why would you use one?

A Web Worker runs JavaScript in a separate background thread, independent of the main thread. You'd use it for CPU-intensive tasks (complex calculations, image processing, data parsing) that would otherwise freeze the UI. Workers communicate with the main thread via postMessage/onmessage and cannot access the DOM.

Q: What are Transferable Objects and why do they matter?

Transferable Objects (like ArrayBuffer, MessagePort, OffscreenCanvas) can be transferred from one thread to another with zero-copy — ownership moves rather than data being cloned. This is critical for performance when passing large data (like image buffers) to workers. After transfer, the original reference becomes empty (zero bytes).

Q: What is the difference between a Worker and a SharedWorker?

A Worker is dedicated to the script that created it — one worker per script instance. A SharedWorker is shared across multiple browsing contexts (tabs, iframes, windows) from the same origin. SharedWorkers use port-based communication and are useful for shared state, reducing redundant connections (e.g., a single WebSocket shared across tabs).

Q: How do you communicate between the main thread and a Worker?

Both sides use postMessage(data, [transferList]) to send and an onmessage handler (or an addEventListener('message', ...)) to receive. Data is passed by structured clone (deep copy) by default; for large buffers, include them in the transfer list to move ownership with zero copying. Errors from inside the worker surface on the main thread via worker.onerror.

Q: Can a Web Worker access the DOM? What can it access?

No — workers have no access to document, window, or parent DOM. They can use fetch, WebSocket, IndexedDB, setTimeout/setInterval, crypto, OffscreenCanvas, and they can import other scripts via importScripts() or ES module imports. They can also spawn sub-workers. Any DOM update must be done by posting a message back to the main thread.


Quick Reference — Cheat Sheet

WEB WORKERS — MOVE CPU WORK OFF MAIN THREAD

LIFECYCLE
  const w = new Worker("worker.js")
  w.postMessage(data, [transferList])
  w.onmessage = (e) => use(e.data)
  w.onerror   = (err) => ...
  w.terminate()                          // force kill

INSIDE THE WORKER
  self.onmessage = (e) => { ... self.postMessage(out) }
  importScripts("helper.js")             // classic
  import { helper } from "./h.js"         // module worker

TRANSFERABLE OBJECTS (ZERO-COPY)
  ArrayBuffer, MessagePort, ImageBitmap,
  OffscreenCanvas, ReadableStream
  postMessage(obj, [arrayBuffer])

CAN'T ACCESS       CAN ACCESS
  DOM                fetch, WebSocket, IndexedDB
  window/document    setTimeout, crypto
  localStorage       OffscreenCanvas, Cache API

TYPES
  Worker        -> 1 script, 1 worker
  SharedWorker  -> 1 worker shared across tabs (port-based)
  ServiceWorker -> proxy between page and network (PWAs)

COMLINK
  Comlink.expose(api)    // inside worker
  const api = Comlink.wrap(new Worker("w.js"))
  await api.method(args) // feels like async

Previous: Lazy Loading -> Only Fetch What the User Actually Needs Next: requestIdleCallback -> Non-Critical Work Without Blocking Frames


This is Lesson 13.3 of the JavaScript Interview Prep Course — 14 chapters, 87 lessons.

On this page