Use Web Workers for Heavy Computation Without Freezing UI
Advertisement
🧩 Scenario
Architecture Walkthrough
Designing the Message Protocol
A worker communicates with the main thread only through postMessage. Without a protocol, a worker handling multiple task types becomes hard to extend. A structured message protocol uses a type field to identify the operation and a payload field for the data. The response uses the same structure plus an id field to match responses to the original requests, enabling multiple in-flight tasks to a single worker without confusing their responses.
Workers are long-lived objects. Creating a new Worker for each task incurs OS thread startup cost (typically 50 to 100ms) and memory overhead for the new JavaScript runtime. Reusing a pool of pre-created workers eliminates this overhead for repeated tasks.
Transferable Objects for Large Data
postMessage copies data by default using the structured clone algorithm. Transferring a 10MB ArrayBuffer via structured clone copies 10MB of memory, which takes measurable time and doubles the memory footprint temporarily. Transferable objects transfer ownership instead of copying: the main thread passes the buffer in the second argument to postMessage as an array of transferable items. The transfer is O(1); the main thread's buffer becomes neutered (.byteLength === 0) and the worker owns the same memory.
After the worker finishes processing, it can transfer the result buffer back to the main thread using the same mechanism, keeping data transfer fast in both directions.
Worker Pool for Parallelism
A worker pool creates N workers at initialization (typically navigator.hardwareConcurrency or a fixed limit like 4) and maintains a queue of pending tasks. When a worker finishes a task, it picks up the next task from the queue. Each task is represented as a pending Promise that resolves when the worker posts a result.
The pool maps task IDs to their resolve/reject callbacks so the onmessage handler can route each response to the correct caller.
Key Code Explained
// worker.js: message protocol with type and id
self.onmessage = function ({ data }) {
const { type, id, payload } = data;
try {
let result;
if (type === 'GRAYSCALE') {
result = applyGrayscale(payload.imageData); // returns ImageData or ArrayBuffer
} else if (type === 'RESIZE') {
result = resize(payload.imageData, payload.width, payload.height);
} else {
throw new Error(`Unknown task type: ${type}`);
}
// Transfer the result buffer back — no copy
self.postMessage({ id, result }, [result.buffer]);
} catch (err) {
self.postMessage({ id, error: err.message });
}
};
// main.js: simple worker wrapper with Promise interface
class ImageWorker {
constructor() {
this.worker = new Worker('/workers/image.js');
this.pending = new Map(); // id -> { resolve, reject }
this.nextId = 0;
this.worker.onmessage = ({ data }) => {
const { id, result, error } = data;
const { resolve, reject } = this.pending.get(id);
this.pending.delete(id);
if (error) reject(new Error(error));
else resolve(result);
};
this.worker.onerror = (err) => {
// Reject all pending tasks if the worker crashes
this.pending.forEach(({ reject }) => reject(new Error(err.message)));
this.pending.clear();
};
}
process(type, payload, transferables = []) {
const id = this.nextId++;
return new Promise((resolve, reject) => {
this.pending.set(id, { resolve, reject });
this.worker.postMessage({ type, id, payload }, transferables);
});
}
terminate() {
this.worker.terminate();
}
}
// Usage: transfer the ArrayBuffer to avoid copying
const worker = new ImageWorker();
const buffer = await file.arrayBuffer(); // user-uploaded image as ArrayBuffer
const result = await worker.process('GRAYSCALE', { imageData: buffer }, [buffer]);
// buffer.byteLength === 0 here — ownership transferred to worker
// result is a new ArrayBuffer transferred back from the worker
// Fallback: requestIdleCallback for environments without Worker
async function processWithFallback(imageData) {
if (typeof Worker === 'undefined') {
return new Promise((resolve) => {
requestIdleCallback(() => {
resolve(applyGrayscale(imageData)); // runs on main thread during idle time
});
});
}
return worker.process('GRAYSCALE', { imageData }, [imageData.buffer]);
}
The this.worker.onerror handler is the most commonly forgotten piece. If a worker throws an unhandled exception, the onerror event fires on the main thread. Without this handler, all pending Promises from in-flight tasks would never resolve or reject, leaving the UI in a loading state forever.
Tradeoffs
| Data transfer method | Cost | Main thread access after transfer | Use when |
|---|---|---|---|
| Structured clone (default) | O(n) copy | Still has original | Small data or if you need to keep it |
| Transferable (ArrayBuffer) | O(1) transfer | Neutered (byteLength === 0) | Large images, video, audio data |
| SharedArrayBuffer | Zero copy, shared memory | Shared with worker | Requires Atomics, COOP/COEP headers |
What Interviewers Actually Check
- Whether you can design a typed message protocol with IDs for matching requests to responses
- Whether you know Transferable objects and why they are better than structured clone for large buffers
- Whether you know to implement
worker.onerrorto handle worker crashes - Whether you can implement a worker pool rather than spawning a new worker per task
- Whether you know a fallback strategy when
Workeris not available
Follow-Up Questions
- How would you add a task timeout so that if a worker does not respond within N seconds, the task is cancelled and the worker is terminated?
- How does
OffscreenCanvaswork with Web Workers, and how would you use it for canvas-based rendering off the main thread? - What is a
ServiceWorkerand how does it differ from aDedicatedWorkerin terms of scope, lifecycle, and use case? - How would you implement a progress reporting mechanism where the worker posts percentage updates back to the main thread during a long computation?
- If a worker pool has 4 workers and all 4 are busy, how does the pool handle a new task request?
Common Candidate Mistakes
- Not using Transferable objects for
ArrayBufferand paying a full copy cost for every large image transfer in both directions - Creating a new
Workerfor each image file rather than reusing a pool, incurring startup cost on every upload - Not implementing
worker.onerror, leaving pending Promises unresolved when the worker crashes - Not providing a fallback for environments where
Workeris undefined, causing the feature to silently fail - Trying to send a DOM node, a function, or a
SymbolviapostMessage, which throws because those types are not cloneable
Interview Readiness Checklist
Before you leave this question, make sure you can answer:
- Can you design a worker message protocol with type, id, and payload fields?
- Can you explain the difference between structured clone transfer and Transferable object transfer?
- Can you implement a basic worker pool that dispatches tasks and reuses workers?
- Can you implement error handling that propagates worker errors to the main thread caller?
- Can you describe a fallback strategy using
requestIdleCallbackfor environments without worker support?
Summary
Web Workers run JavaScript in a separate OS thread. Communication happens exclusively through postMessage and onmessage. A structured message protocol with a type and id field allows a single worker to handle multiple operation types and match responses back to their originating Promises.
Data transfer is the performance bottleneck for large binary payloads. Passing an ArrayBuffer via structured clone copies the entire buffer. Passing it as a Transferable object transfers ownership in O(1) time. After transfer, the original buffer on the sending thread is neutered. The receiving thread owns the memory and can transfer it back the same way when done.
For applications that perform many worker tasks (image processing on upload, document parsing), a worker pool eliminates the per-task startup cost. The pool maintains N pre-created workers and a queue of pending tasks, dispatching each task to the first available worker and routing its response back to the caller via a pending Promise map.
Can Web Workers access the DOM?
No. Workers run in a separate global scope and cannot access window or document directly. Use postMessage to send results back to the main thread.
Advertisement
Stay Updated
Get the latest frontend challenges, interview questions and tutorials delivered to your inbox.
Advertisement