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
forloop 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
postMessageboilerplate intoawait 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/onmessagecommunication works and when to use Transferable Objects for zero-copy transfer - The difference between
Worker,SharedWorker, andServiceWorker, 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);
};
Worker Limitations and Comlink
// 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, andlocalStorageare 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/onmessageand 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
Workeris dedicated to the script that created it — one worker per script instance. ASharedWorkeris shared across multiple browsing contexts (tabs, iframes, windows) from the same origin. SharedWorkers useport-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 anonmessagehandler (or anaddEventListener('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 viaworker.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 usefetch,WebSocket,IndexedDB,setTimeout/setInterval,crypto,OffscreenCanvas, and they can import other scripts viaimportScripts()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.