JavaScript Web Workers: Running Code on a Separate Thread
JavaScript is single-threaded — every script you write runs on the main thread, sharing one event loop with the UI. Web Workers are the escape hatch: a way to run JavaScript on a separate thread that doesn't block the UI and can take advantage of multi-core hardware.
This lesson covers how Workers actually work, the postMessage interface, structured cloning, the limits (no DOM access, no shared variables), and the practical use cases — heavy computation, parsing big files, image processing, AI inference in the browser.
The threading model #
The browser runs your main JavaScript on a single thread, alongside rendering, input handling, and layout. Any long task on that thread freezes the UI. Animation jank, unresponsive buttons, the "Page Not Responding" dialog — all caused by main-thread blocking.
A Web Worker runs in its own thread:
- Separate global scope — no access to
window,document, or any DOM. - Separate event loop — runs independently of the main thread.
- Communicates via messages —
postMessagesends data both ways. - No shared memory by default — data is cloned across thread boundaries.
This is share-nothing concurrency, the same model Erlang and Actor-based systems use. It's verbose compared to shared-memory threads, but it eliminates an entire class of concurrency bugs.
Creating a worker #
A worker is a separate JavaScript file. Spawn it with the Worker constructor:
// main.js
const worker = new Worker('worker.js', { type: 'module' });
worker.addEventListener('message', (e) => {
console.log('from worker:', e.data);
});
worker.postMessage({ task: 'sum', payload: [1, 2, 3, 4, 5] });
// worker.js
self.addEventListener('message', (e) => {
const { task, payload } = e.data;
if (task === 'sum') {
const total = payload.reduce((s, n) => s + n, 0);
self.postMessage(total);
}
});
self in a worker refers to its own global scope (instead of window). The pattern is symmetric — both sides post messages and listen for them.
Module workers #
The { type: 'module' } option lets the worker file use import/export. Without it, the worker is a classic script and must use importScripts(...).
Module workers are the modern default. Stick with them unless you have a specific reason.
Inline workers via Blob #
For self-contained code, you can spawn a worker without a separate file:
const src = `
self.addEventListener('message', e => {
self.postMessage(e.data * 2);
});
`;
const blob = new Blob([src], { type: 'application/javascript' });
const worker = new Worker(URL.createObjectURL(blob), { type: 'module' });
Useful for libraries that ship a single bundle. Real apps almost always use separate worker files for clarity.
What can be sent between threads #
The data you postMessage is structured-cloned — copied across the thread boundary. The structured clone algorithm handles:
- Primitives (number, string, boolean, null, undefined, BigInt)
- Plain objects, arrays
- Date, RegExp
- Map, Set
- ArrayBuffer, TypedArrays (Uint8Array, etc.)
- Blob, File, ImageData
- Errors
It does NOT handle:
- Functions (can't be cloned)
- DOM nodes (workers can't touch the DOM anyway)
- Class instances with private fields (returned as plain objects)
- Property descriptors / getters / setters (not preserved)
If you need to send something the clone algorithm can't handle, serialize it yourself (JSON, custom format).
Transferable objects #
For large data (image buffers, audio, big arrays), copying is expensive. Some objects can be transferred — moved across the thread boundary in O(1) without copying. The original becomes unusable.
const buffer = new ArrayBuffer(1024 * 1024); // 1 MB
worker.postMessage({ buf: buffer }, [buffer]);
// Second argument lists transferables
// buffer is now detached on the main thread:
console.log(buffer.byteLength); // 0
Transferable types: ArrayBuffer, MessagePort, ImageBitmap, OffscreenCanvas, ReadableStream, WritableStream, TransformStream.
For large data, transferring is dramatically faster than cloning. Worth the awkward second argument.
What the worker CAN do #
A worker has its own globals — many Web APIs are available:
fetchsetTimeout,setIntervalcryptoIndexedDBWebSocketWebAssemblyconsoleOffscreenCanvas(for rendering in a worker)- Other Workers (workers can spawn workers)
What it CAN'T do:
- Touch the DOM (
document,window— none of it) - Access
localStorageorsessionStorage(IndexedDBworks) - Modify the URL or trigger navigation
- Read cookies directly
Workers are deliberately sandboxed from page-level state. They're for computation, not UI.
Practical use cases #
Heavy computation #
The canonical use case. Anything that takes >50 ms on the main thread is a candidate for a worker:
- Image processing (resize, filter, OCR)
- PDF parsing, generation
- Cryptographic operations (hashing large files, signing)
- Compilation (Markdown → HTML, syntax highlighting at scale)
- JSON parsing of multi-megabyte responses
- Search indexing (e.g., lunr.js, MiniSearch)
- Spreadsheet recalculation
- AI/ML model inference (TensorFlow.js, ONNX.js)
Parsing big files #
If the user drops a 100 MB CSV into your app, parsing it on the main thread freezes the UI for seconds. In a worker:
// main.js
const worker = new Worker('csv-worker.js', { type: 'module' });
worker.postMessage(file); // File object is structured-cloneable
worker.onmessage = (e) => updateProgress(e.data);
The main thread stays responsive. Progress updates flow back via messages.
Streaming AI inference #
A worker can hold a TensorFlow.js or ONNX model and run inference per request from the main thread:
// model-worker.js
import * as tf from '@tensorflow/tfjs';
const model = await tf.loadGraphModel('/model.json');
self.addEventListener('message', async (e) => {
const input = tf.tensor(e.data);
const output = model.predict(input);
self.postMessage(await output.data());
});
The heavy initial model load doesn't block the main thread; subsequent inferences run off-main.
Patterns #
Promise-shaped messaging #
Raw postMessage is event-driven. Wrap it into Promises for cleaner code:
class WorkerRPC {
constructor(url) {
this.worker = new Worker(url, { type: 'module' });
this.calls = new Map();
this.id = 0;
this.worker.addEventListener('message', (e) => {
const { id, result, error } = e.data;
const { resolve, reject } = this.calls.get(id);
this.calls.delete(id);
error ? reject(new Error(error)) : resolve(result);
});
}
call(payload) {
return new Promise((resolve, reject) => {
const id = ++this.id;
this.calls.set(id, { resolve, reject });
this.worker.postMessage({ id, payload });
});
}
}
// worker side
self.addEventListener('message', async (e) => {
const { id, payload } = e.data;
try {
const result = await doWork(payload);
self.postMessage({ id, result });
} catch (err) {
self.postMessage({ id, error: err.message });
}
});
// usage
const rpc = new WorkerRPC('worker.js');
const answer = await rpc.call({ task: 'sum', nums: [1, 2, 3] });
This is the foundation of every Web Worker library. The comlink library does it for you with a Proxy on top that makes worker calls look like local function calls.
Comlink #
// worker.js
import { expose } from 'comlink';
export class API {
add(a, b) { return a + b; }
async loadAndProcess(url) { /* ... */ }
}
expose(API);
// main.js
import { wrap } from 'comlink';
const Api = wrap(new Worker('worker.js', { type: 'module' }));
const api = await new Api();
const sum = await api.add(2, 3); // 5 — even though add() runs in the worker
For production worker code, comlink is the default choice. It hides the message-passing and gives you a clean object interface.
SharedArrayBuffer and Atomics #
For genuinely shared memory between threads (instead of message-passing), SharedArrayBuffer lets two workers reference the same memory.
const sab = new SharedArrayBuffer(1024);
const view = new Int32Array(sab);
worker.postMessage(sab); // not transferred — both sides reference the same memory
Atomics.store(view, 0, 42);
Atomics.load(view, 0); // 42
Requires special HTTP headers (Cross-Origin-Opener-Policy, Cross-Origin-Embedder-Policy) due to Spectre mitigations. Useful for high-performance use cases (WebAssembly threading, real-time audio processing).
For most app code, structured cloning is plenty.
Service Workers vs Web Workers vs Shared Workers #
Three confusingly-named cousins:
| Type | Lifetime | Purpose |
|---|---|---|
| Web Worker | Tied to its page | Heavy computation |
| Shared Worker | Shared across pages of the same origin | Coordinate across tabs |
| Service Worker | Background, survives page navigation | Caching, offline, push notifications |
This lesson is about Web Workers. Service Workers get their own lesson (the next one — 11.4) because they're a completely different mental model (event-driven, persistent, network-intercepting).
A summary #
- Web Workers run on a separate thread. No DOM access, no shared variables.
- postMessage + structured cloning is the standard interface. Transferables for large data.
- What workers can use: fetch, IndexedDB, WebSocket, Crypto, Wasm, OffscreenCanvas.
- Use cases: anything that takes >50 ms — parsing, image work, crypto, AI inference, search indexing.
- Wrap postMessage in Promises for sanity. Or use
comlink. - Module workers (
{ type: 'module' }) are the modern default. - SharedArrayBuffer for shared memory; rarely needed in app code.
What's next #
That completes Module 10. Module 11 covers the rest of the browser API surface — IndexedDB, observers (Intersection, Resize, Mutation), Web Components, and Service Workers / PWAs.
Try it yourself #
The main-thread vs worker-thread contrast is the easiest to feel. Predict the difference:
// Main thread version
button.addEventListener('click', () => {
for (let i = 0; i < 1e10; i++) {} // ~5 sec loop
console.log('done');
});browser_sandboxThe page locks up for ~5 seconds. Clicks elsewhere don’t register. CSS animations freeze. The browser may show a “Page Unresponsive” dialog.Move the same loop into
worker.js via postMessage, and the page stays smooth — the user can interact, animations run, and you get a 'done' message back when the worker finishes.That’s the entire pitch of Web Workers: anything you can move off the main thread, you should.
Knowing exactly when to reach for a Worker — anything reliably over ~50 ms — is the single biggest jank-prevention skill in front-end engineering.
Up next in JavaScript
More from this topic
Enjoyed this article?
Get new JavaScript tutorials delivered. No spam — just code-first articles when they ship.


