JavaScript Web Workers: Running Code on a Separate Thread

Link copied
JavaScript Web Workers: Running Code on a Separate Thread

JavaScript Web Workers: Running Code on a Separate Thread

JS Tutorial Module 10: Advanced Lesson 10.4

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 messagespostMessage sends 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:

  • fetch
  • setTimeout, setInterval
  • crypto
  • IndexedDB
  • WebSocket
  • WebAssembly
  • console
  • OffscreenCanvas (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 localStorage or sessionStorage (IndexedDB works)
  • 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.

// 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:

YouWhat happens visually?
// Main thread version
button.addEventListener('click', () => {
for (let i = 0; i < 1e10; i++) {} // ~5 sec loop
console.log('done');
});
Claude · used 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

View all JavaScript articles →

Enjoyed this article?

Get new JavaScript tutorials delivered. No spam — just code-first articles when they ship.

Leave a Comment

Your email stays private. Required fields are marked *

Leave a Comment

Your email stays private. Required fields are marked *