Web workers
Why a web worker keeps the page responsive
JavaScript that runs for a long time on the page's main thread can delay clicks, rendering, and other interface work. A web worker runs a script in a separate worker context. CPU-heavy parsing, image processing, or number crunching can happen there while the main thread remains available for the interface.
Moving a calculation into a Promise does not create a worker:
const result = await Promise.resolve(expensiveCalculation());
expensiveCalculation() still runs before Promise.resolve() receives its value. A dedicated worker creates the separate execution context:
const worker = new Worker(new URL("./filter-worker.js", import.meta.url), {
type: "module",
});
worker.postMessage({ pixels, width, height });
worker.onmessage = (event) => {
renderResult(event.data);
};
Inside filter-worker.js, the worker receives the message and posts a result:
self.onmessage = (event) => {
const result = applyFilter(event.data);
self.postMessage(result);
};
The worker has its own global context. window and the parent page's DOM are unavailable there. APIs documented for workers, including fetch(), are available, but a worker sends a message back when the page needs to update an element.
Wrong "The worker can call document.querySelector() because it belongs to the same page."
Right The worker cannot directly manipulate the parent page. It posts data to the main script, which performs the DOM update.
Messages cross the boundary
The main script and worker do not call into each other's local variables. Both sides use postMessage(), then read the received value from a message event's data property.
Most ordinary message data is copied using the structured clone algorithm. The receiver gets a separate value rather than the same object identity. Structured clone supports values that JSON cannot represent faithfully, including Date, Map, Set, typed arrays, and circular references. Functions and DOM nodes cannot be cloned; attempting to send them produces a DataCloneError. Transferable and explicitly shared objects are exceptions to the copy behavior.
Wrong "postMessage() passes the same object reference to the other thread."
Right Most ordinary worker message values are copied by structured clone. Transferable and shared objects use different rules, with transfer covered in the middle notes.
Dedicated and shared workers
A dedicated worker belongs to the script that created it. This is the usual choice when one page owns a CPU-heavy job.
A shared worker can be reached by multiple same-origin browsing contexts, such as windows or iframes. Clients communicate through explicit MessagePort objects. A shared worker is useful when those contexts need one coordination point, but it is not durable storage and it does not grant the pages direct access to its variables.
Copy cost can erase the win
Offloading a calculation frees the main thread, but inputs and outputs still cross a boundary. postMessage() applies structured serialization, and most ordinary values are copied when deserialized. Large payloads can make that copy cost significant in the design of the worker protocol.
An ArrayBuffer is transferable. Include it in the message and the transfer list to move its memory resource to the receiver:
const pixels = new Uint8Array(8 * 1024 * 1024);
worker.postMessage(
{ type: "filter", pixels },
[pixels.buffer],
);
console.log(pixels.byteLength); // 0
The typed array is serializable, while its underlying ArrayBuffer is transferable. After transfer, the sender's buffer is detached and its byteLength is zero. Code on the sending side must treat ownership as gone.
The transfer list does not deliver a resource on its own. The resource also has to appear in the message or otherwise be accessible to the receiver:
worker.postMessage(
{ type: "filter", buffer },
[buffer],
);
Wrong "Transfer gives both threads fast mutable access to the same buffer."
Right Transfer moves ownership. Shared memory is a separate mechanism with different synchronization and security concerns.
Rendering with OffscreenCanvas
HTMLCanvasElement.transferControlToOffscreen() returns an OffscreenCanvas, which is also transferable. Transfer control before calling getContext() on the HTML canvas:
const canvas = document.querySelector("canvas");
const offscreen = canvas.transferControlToOffscreen();
worker.postMessage(
{ type: "init-canvas", canvas: offscreen },
[offscreen],
);
The worker can obtain a rendering context from the received canvas:
self.onmessage = (event) => {
if (event.data.type !== "init-canvas") return;
const context = event.data.canvas.getContext("2d");
context.fillRect(0, 0, 100, 100);
};
Calling transferControlToOffscreen() throws InvalidStateError if the HTML canvas already has a context mode or has already transferred control. This ownership boundary is why canvas initialization belongs in one place.
SharedWorker uses ports
Every SharedWorker client talks through worker.port. The worker receives each connection in a connect event:
// page.js
const worker = new SharedWorker("./shared-worker.js", { type: "module" });
worker.port.onmessage = (event) => {
showStatus(event.data);
};
worker.port.postMessage({ type: "status" });
// shared-worker.js
self.onconnect = (event) => {
const port = event.ports[0];
port.onmessage = (message) => {
if (message.data.type === "status") {
port.postMessage({ connected: true });
}
};
};
Assigning onmessage starts a port implicitly. When code registers the message handler with addEventListener(), it must call port.start().
Wrong "Shared means any tab can connect and the worker is permanent."
Right Connecting contexts must have the same origin. By default, a shared worker stops being actively needed when its owners disappear. The extendedLifetime constructor option can request a short period for work after that point, but it does not make the worker permanent.
Worker protocols need explicit messages
Worker communication is asynchronous message delivery between separate global contexts. A useful protocol identifies the operation and correlates replies. It also makes cancellation and stale-result handling explicit at the application layer.
const pending = new Map();
let nextId = 0;
function runFilter(buffer) {
const id = nextId++;
const result = new Promise((resolve, reject) => {
pending.set(id, { resolve, reject });
});
worker.postMessage({ id, type: "filter", buffer }, [buffer]);
return result;
}
worker.onmessage = ({ data }) => {
const request = pending.get(data.id);
if (!request) return;
pending.delete(data.id);
request.resolve(data.buffer);
};
This wrapper exposes a Promise-returning function while retaining the transfer semantics. runFilter() transfers buffer, so the caller cannot continue using that ArrayBuffer. The worker should transfer a result buffer back when it finishes.
Terminating a worker is abrupt. The HTML processing model sets its closing flag, discards queued tasks, and aborts the script currently running. If a protocol promises graceful completion, implement that through messages before calling terminate() and be prepared for the worker to disappear.
Structured clone preserves values selectively
Structured clone supports cycles and many built-in data types, but it is not a general object-graph copier. Functions and DOM nodes fail with DataCloneError. Property descriptors, getters, setters, class private elements, and the prototype chain are not duplicated.
class Job {
constructor(id) {
this.id = id;
}
run() {
return this.id;
}
}
worker.postMessage({ id: new Job("job-7").id });
Send plain data and reconstruct behavior inside the receiving context. This also keeps the protocol independent of a class definition that exists in only one realm.
Wrong "If an object is cloneable, its prototype methods and property semantics survive."
Right Cloneability does not preserve the prototype chain or property descriptors. Define the message as data with an explicit shape.
Shared workers coordinate active owners
A SharedWorker can accept ports from several same-origin owners. Each port represents a client connection, so the worker must track connection-specific state and close ports that are no longer needed. By default, the worker stops being actively needed when its owners disappear. The extendedLifetime constructor option requests extra time for operations after that point, but the duration is controlled by the user agent.
This makes SharedWorker appropriate for live coordination among open contexts. It is a poor place for state that must survive every page closing. The standard does not promise an indefinitely running process.
Wrong "One SharedWorker gives all tabs direct access to a shared heap."
Right Each client exchanges structured-cloned or transferred messages through a MessagePort. The worker's variables remain in its own global context.
Worklets belong to browser pipelines
A worklet loads a module into a specialized processing environment. Its host API decides when the code runs and what interfaces it receives; the general-purpose Worker API does not apply.
AudioWorklet is the concrete choice for custom processing in a Web Audio graph. Its module is loaded through the owning AudioContext, then a registered processor participates in the graph:
const context = new AudioContext();
await context.audioWorklet.addModule("./gain-processor.js");
const node = new AudioWorkletNode(context, "custom-gain");
node.connect(context.destination);
class CustomGainProcessor extends AudioWorkletProcessor {
process(inputs, outputs) {
const input = inputs[0];
const output = outputs[0];
for (let channel = 0; channel < output.length; channel++) {
if (!input[channel]) continue;
for (let i = 0; i < output[channel].length; i++) {
output[channel][i] = input[channel][i] * 0.5;
}
}
return false; // Let active inputs and references determine node lifetime.
}
}
registerProcessor("custom-gain", CustomGainProcessor);
Paint worklets follow the same host-controlled idea for CSS-generated images, using CSS.paintWorklet.addModule() and registerPaint() where supported.
The HTML Standard ties a worklet's lifetime to the object it belongs to, such as a Window. A defining specification supplies the rest of the lifecycle rules. Web Audio says an AudioWorkletGlobalScope must not be terminated arbitrarily, while the boolean returned from process() participates in the associated node's lifetime. AudioWorklet has no Worker-like terminate() method and is not a general worker pool.
dig deeper
Primary sources behind these notes - the specs and official docs worth reading in full.
beta
The interviewer part is in the works.
The diagnostic, personal maps, and AI mock interviews are being finished right now. The notes stay free either way. Leave an email and you'll get the first-cohort invite, plus a month of Pro when it opens.