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


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

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