#AI#TypeScript

Web Workers: Multithreading in the Browser

JavaScript is single-threaded, so heavy computation can freeze the UI. Web Workers run JavaScript on background threads without blocking the main thread. This article covers postMessage, CPU-intensive use cases, Dedicated, Shared, and Service Workers, transfer strategies, Comlink, and WASM integration.

Key Points

  • Topic Focus: AI、TypeScript
  • Suitable for: indie hackers, full-stack engineers, AI application developers
  • Core value: quickly understand background, practical paths, common pitfalls, and engineering advice
Author @admin·Published July 26, 2026·Updated July 26, 2026

Most frontend developers have encountered this scene: the page starts a heavy computation, such as parsing a JSON file tens of megabytes in size, processing an image, or performing cryptography, and the entire UI freezes. Buttons stop responding, scrolling locks up, and animations drop frames. You open the Performance panel in DevTools and see one Long Task occupying the main thread from end to end.

There is only one root cause: JavaScript is single-threaded by default in the browser, and UI rendering and JS run on the same main thread. Both compete for one thread, and time slicing determines which runs first.

Web Workers are the browser's escape hatch: start a real operating-system thread, run JS in the background, and leave the main thread free to handle the UI. This article explains how Workers work, how to use them, their pitfalls, and how to integrate them into production projects.

1. The Single-Threaded JS Problem: Why Workers Are Necessary

First, consider the problem. The browser's rendering pipeline works roughly like this: the main thread alternates between running JS and performing style calculation, layout, and paint. Once JS occupies the main thread for too long, such as when a synchronous loop runs for several hundred milliseconds, subsequent rendering tasks cannot run, and the user perceives the page as "frozen."

At 60 FPS, each frame has only 16.6ms for all JavaScript, layout, and painting to complete. A 50ms task immediately costs three frames.

Worse, Chrome marks any task longer than 50ms as a Long Task, which directly affects INP, the interaction responsiveness metric. In other words, heavy computation not only makes the interface feel sluggish but also degrades your Core Web Vitals score and affects SEO.

Why not simply make JavaScript multithreaded? The answer is historical: the DOM is shared, and allowing multiple threads to manipulate it simultaneously would introduce enormous concurrency complexity, including races, locks, and deadlocks. Browser vendors originally chose a single-threaded model for simplicity and stability. It is a trade-off, but the cost is clear: all heavy work must yield the main thread.

Web Workers are the remedy for that trade-off. They give you an independent thread, at the cost that the thread cannot access the DOM.

2. How It Works: Independent Threads + postMessage Communication

A Web Worker runs in a real operating-system thread, with its own event loop and global object (self instead of window). It does not share memory with the main thread by default and can communicate only through postMessage.

The communication model is message passing, not shared state. This matters: it eliminates data races by design, but data must be "moved" between the two threads.

Here is the smallest practical example on the main thread:

ts
// main.ts
const worker = new Worker(new URL('./heavy.worker.ts', import.meta.url), {
  type: 'module',
});

worker.onmessage = (e) => {
  console.log('Result:', e.data);
};

worker.postMessage({ input: bigArray });

The Worker file:

ts
// heavy.worker.ts
self.onmessage = (e) => {
  const result = doExpensiveWork(e.data.input); // Run on the background thread
  self.postMessage(result);
};

function doExpensiveWork(input: number[]) {
  // CPU-intensive computation can run as long as necessary here without blocking the UI
  return input.sort((a, b) => a - b);
}

A few engineering details deserve attention:

  • The new URL('./x.worker.ts', import.meta.url) pattern is bundler-friendly (Webpack/Vite/Next.js all recognize it) and correctly creates a separate Worker bundle. Do not use a plain string path, which loses type information and bundling support.
  • type: 'module' enables ESM and import inside the Worker. It is nearly always necessary in modern projects.
  • Exceptions thrown by a Worker do not automatically propagate to the main thread. Listen for worker.onerror, or failures may go unnoticed.
  • In TypeScript, a Worker's self is DedicatedWorkerGlobalScope. You may need to declare the type manually so that the e parameter in self.onmessage is inferred correctly.

3. When to Use a Worker and When Not to

This is the most important decision. A Worker is not a silver bullet, and using one in the wrong place can be worse than not using one, because communication itself has overhead.

Good use cases for Workers (fundamentally, CPU-intensive tasks whose execution time is much greater than communication overhead):

  • Large-scale data computation: sorting large arrays, numerical computation, and complex algorithms such as pathfinding and graph algorithms
  • Large JSON parsing: parsing JSON files tens of megabytes in size with JSON.parse can block the main thread for hundreds of milliseconds, so move it into a Worker
  • Image/video processing: use OffscreenCanvas to perform Canvas rendering and pixel operations such as filtering, compression, and recognition inside a Worker
  • Cryptography/compression: AES, SHA, gzip, and zstd libraries often have synchronous implementations that consume substantial CPU time
  • Real-time data-stream processing: filtering, transforming, and aggregating stock quotes or sensor data
  • AI inference: running model inference with TensorFlow.js / ONNX Runtime Web in a Worker is standard practice

Poor use cases for Workers:

  • DOM operations: Workers have no document, window, or localStorage. Attempting to access the DOM throws an error. This is a hard limitation.
  • Simple logic: if summing or filtering a hundred items takes 1ms, sending it back and forth with postMessage costs more than the computation. Rule of thumb: if a task takes less than 10ms, do not start a Worker.
  • Pure I/O waits: a fetch network request is already asynchronous and nonblocking on the main thread. A Worker will not make the network faster.
  • Frequent small messages: postMessage uses structured cloning, so high-frequency small messages accumulate overhead.

In short: the criterion is "computational density," not whether the task looks complicated.

4. Three Types of Workers: Dedicated / Shared / Service

Many people do not realize that there are three types of Workers. Each serves a different purpose, so do not confuse them.

1. Dedicated Worker

This is the new Worker(...) form shown above. It is bound to one page, and the Worker is destroyed when the page closes. It is sufficient for 99% of use cases.

2. Shared Worker

A new SharedWorker(...) can be shared by multiple same-origin browser tabs. For example, if you have five tabs open for the same application and want them to share one WebSocket connection or cache, a Shared Worker lets you create only one instance.

However, the Shared Worker API is more cumbersome because it uses a port mechanism and requires a manual port.start(). Browser support and commitment have also remained uncertain (Chrome has considered changes, and Service Workers have replaced it in some scenarios). My recommendation is: do not use one unless you genuinely need to share state across tabs. BroadcastChannel plus the main thread can handle most cross-tab sharing.

3. Service Worker

This is a different species. Although its name contains "Worker," its role is entirely different: it is the proxy layer for PWA offline caching, Push notifications, and background synchronization. It intercepts network requests, manages Cache Storage, runs with an independent lifecycle, has no DOM access, and can be awakened or suspended by the browser in the background.

A Service Worker is not designed for computation. It is for building an "offline Web App." If you need a PWA, offline access, or Web Push, this is the right tool. If you need parallel computation, using a Service Worker is the wrong choice, and its lifecycle and startup latency will cause endless frustration.

Remember: Dedicated Workers handle computation, Service Workers handle offline capabilities, and Shared Workers handle cross-tab sharing.

5. Communication Performance: Transferable and SharedArrayBuffer

By default, postMessage transfers data using structured cloning: the main thread copies the object for the Worker. Small data is fine, but cloning a 100MB ArrayBuffer consumes another 100MB of memory and takes tens of milliseconds. Both sides retain a copy, doubling memory usage.

There are two optimization techniques every engineer should know.

1. Transferable Objects

Objects such as ArrayBuffer, MessagePort, and OffscreenCanvas support "ownership transfer." Transfer is zero-copy, and the object becomes unusable (neutered) on the original thread:

ts
const buffer = new ArrayBuffer(50 * 1024 * 1024); // 50MB

// Normal transfer: cloned, slow, and doubles memory usage
worker.postMessage(buffer);

// Ownership transfer: zero-copy; buffer is no longer usable on the main thread
worker.postMessage(buffer, [buffer]);

The second argument is the transfer list. This is appropriate when moving a large block of data once, such as during image processing: the main thread sends pixel data to the Worker, and the Worker transfers it back after processing. Be careful: after the transfer, the original variable has effectively been "confiscated." Reading it returns an ArrayBuffer with a length of zero. This is a common pitfall.

2. SharedArrayBuffer (Shared Memory)

A more aggressive approach lets both threads directly share the same memory, with no copying at all. It comes with two costs:

  • You must use the Atomics API for synchronization, or you will introduce data races (Workers can also use Atomics.wait/notify as condition variables).
  • The page must set the COOP/COEP security headers (Cross-Origin-Opener-Policy: same-origin + Cross-Origin-Embedder-Policy: require-corp), or the browser will refuse to enable it. These headers affect how third-party iframes and resources load, so you must evaluate their impact before deployment.

This is suitable for genuinely high-frequency, large-data, real-time processing, such as analyzing a video stream frame by frame. Transferable is sufficient for ordinary applications. SharedArrayBuffer is an advanced tool, so do not overuse it.

Native postMessage becomes tedious with repeated use: every interaction requires message types, an onmessage handler, and request/response mapping. For RPC-style requirements such as "call a function," the boilerplate becomes long and unpleasant.

Comlink, created by the Google Chrome team, solves exactly this problem: it wraps Worker communication as ordinary async function calls.

On the Worker side:

ts
// calc.worker.ts
import * as Comlink from 'comlink';

const api = {
  async sort(data: number[]) {
    return data.sort((a, b) => a - b);
  },
  async heavyCompute(n: number) {
    // Pretend this is expensive
    let s = 0;
    for (let i = 0; i < n; i++) s += i;
    return s;
  },
};

Comlink.expose(api);

On the main thread:

ts
// main.ts
import * as Comlink from 'comlink';

const worker = new Worker(new URL('./calc.worker.ts', import.meta.url), {
  type: 'module',
});
const calc = Comlink.wrap<typeof import('./calc.worker.ts')>(worker);

// Call methods in the Worker just like local async functions
const sorted = await calc.sort(bigArray);
const result = await calc.heavyCompute(1_000_000);

Notice the Comlink.wrap<typeof import('./calc.worker.ts')> line: types are inferred directly from the Worker file. The main thread receives a typed async API that feels exactly like calling local functions, with complete type checking.

Several Comlink highlights:

  • The API is the interface: whatever the Worker exposes is available to the main thread, creating a clear boundary.
  • Callbacks are supported: Comlink.proxy(fn) can proxy functions across the boundary, including bidirectional callbacks.
  • Transferable is supported: Comlink.transfer(value, [buffer) explicitly declares an ownership transfer.
  • Type safety: combined with TypeScript's typeof import, it requires almost no boilerplate.

My engineering recommendation is: use Comlink whenever your Worker communication is not a high-frequency data stream, which is better served by raw postMessage. It offers much better readability, maintainability, and testability, and new team members can understand it immediately.

7. WASM + Worker: The Ultimate Performance Combination

If a Worker solves "do not block the UI," WASM solves "compute fast enough." WASM (WebAssembly) lets browsers execute bytecode at near-native speed. Compute-intensive code compiled from C++/Rust/Go is often several to dozens of times faster than pure JS.

But WASM still runs on whichever thread invokes it by default. Place the WASM instance inside a Worker, and you get both benefits: near-native computation speed and a responsive UI.

A typical architecture:

ts
// wasm.worker.ts
import * as Comlink from 'comlink';
import init, { process_image } from '../pkg/image_proc'; // Output from Rust wasm-pack

let ready: Promise<void>;

const api = {
  async init() {
    ready = init(); // Load .wasm
    await ready;
  },
  async process(buffer: ArrayBuffer) {
    await ready;
    return process_image(buffer); // Image processing at native speed
  },
};

Comlink.expose(api);

On the main thread:

ts
const worker = new Worker(new URL('./wasm.worker.ts', import.meta.url), {
  type: 'module',
});
const img = Comlink.wrap<typeof import('./wasm.worker.ts')>(worker);

await img.init();
const result = await img.process(bigImageBuffer);

This is the highest-performance architecture available in the browser: compile Rust/C++ to WASM for computation, isolate it in a Worker, use Comlink for communication, and use Transferable for zero-copy data transfer. Image recognition, video encoding and decoding, complex simulations, and on-device AI inference, tasks that once had to run on a server, can now be handled by the frontend.

Summary

  • JS's single-threaded model is the root cause of rendering stalls. Heavy computation occupies the main thread, freezing the UI and destroying INP. A Worker gives you a real background thread, at the cost of having no DOM access.
  • The criterion is "computational density": only CPU-intensive work taking more than 10ms is generally worth moving into a Worker. Simple logic, pure I/O, and DOM operations are unsuitable because communication overhead may exceed the benefit.
  • Each Worker type has a distinct role: Dedicated for computation, Service for offline/PWA capabilities, and Shared for cross-tab sharing. Dedicated Workers are sufficient for 99% of use cases.
  • Use Transferable for zero-copy transfers of large data, and SharedArrayBuffer for high-frequency real-time processing, with COOP/COEP required. Default structured cloning is a performance sink for large ArrayBuffers.
  • Comlink turns Workers into RPC, infers TypeScript types automatically, and eliminates boilerplate. Use it unless you need high-frequency streaming communication. The ultimate architecture is WASM + Worker + Comlink + Transferable.

One final thought: the main thread should focus only on keeping the experience smooth; send heavy work to a Worker. Users cannot see how hard the background thread is working. They only notice that the page feels responsive. That is why Web Workers exist.

Related

Related Content