JavaScript runs on a single thread. That thread is the problem

Over the past two decades, the web has moved from static pages to full-scale applications, but the underlying execution model hasn't changed: each browser tab still relies on one main thread to render the page and run JavaScript. As apps grow more ambitious, the main thread turns into the primary performance bottleneck. It's also an unpredictable one. How long a script takes depends heavily on the user's device, and the range of devices hitting the web is only widening, from low-end feature phones to high-refresh-rate flagships.

Meeting performance targets such as the Core Web Vitals, which are anchored in empirical findings about human perception, requires moving work off the main thread entirely whenever possible. Web workers are the mechanism that allows that shift.

Where web workers fit

JavaScript executes tasks on the main thread by default. Web workers give developers a way to create separate threads, but they come with strict limitations: no direct DOM access and no access to APIs like WebUSB, WebRTC, or Web Audio. Work that doesn't depend on those browser-only capabilities can still be moved off the main thread productively.

Web workers improve Interaction to Next Paint (INP) by reducing contention for the main thread. With less queued work, the browser can respond faster to user input. At startup, offloading work can collapse long tasks that would otherwise delay rendering of the Largest Contentful Paint (LCP) element. Even a modest reduction in main-thread load for text- or image-heavy renders gives LCP a better chance of completing without being blocked by expensive, non-UI work.

How web workers actually work

In most platforms, threading lets you hand a function to another thread, share state across threads, and use mutexes or semaphores to resolve races. Web workers, which have been supported across major browsers since 2012, also run in parallel with the main thread, but they don't share variables. Instead, you give the worker constructor a file, and that file executes on its own thread:

const worker = new Worker("./worker.js");

Communication happens through the postMessage API. The main thread posts a message to the worker and listens for a response. The worker does the reverse to send results back.

main.js:

const worker = new Worker('./worker.js');
worker.postMessage([40, 2]);

worker.js:

addEventListener('message', event => {
  const [a, b] = event.data;

  // Do stuff with the message
  // ...
});

Returning values follows the same pattern. The worker posts a message after it finishes processing; the main thread listens for it:

main.js:

const worker = new Worker('./worker.js');

worker.postMessage([40, 2]);
worker.addEventListener('message', event => {
  console.log(event.data);
});

worker.js:

addEventListener('message', event => {
  const [a, b] = event.data;

  // Do stuff with the message
  postMessage(a + b);
});

This works, but it gets messy as soon as you need more than an occasional heavy task. Every message has to encode not just parameters but also which operation you're invoking, and you need your own bookkeeping to match a response to the request that triggered it. That overhead is a large part of why web workers have historically been reserved for a narrow set of use cases.

Comlink removes much of that difficulty. Instead of hand-coding the postMessage protocol, you expose functions from the worker and call them directly from the main thread as if they were local.

Setting it up on the worker side requires importing Comlink and exposing an object of functions:

worker.js:

import {expose} from 'comlink';

const api = {
  someMethod() {
    // ...
  }
}

expose(api);

On the main thread, you wrap the worker with Comlink and access those same functions through a proxy:

main.js:

import {wrap} from 'comlink';

const worker = new Worker('./worker.js');
const api = wrap(worker);

From the main thread's perspective, the api object behaves like the original, with one significant difference: every function returns a promise rather than the value directly.

What belongs in a web worker

The immediate limitation is obvious. Any code that reaches for the DOM or depends on a DOM-bound UI framework can't simply be relocated. In a typical React or Vue app, essentially everything is tied to the framework's component model and ultimately to the DOM, which seems to rule out an off-main-thread architecture.

The workaround is architectural: separate UI concerns from pure computational logic and state management, then run only the latter in a worker. PROXX, a PWA-capable Minesweeper clone built by the Google Chrome team, is a concrete demonstration. Its original version froze for six seconds after each interaction on constrained feature phones, with the user receiving no feedback during that interval. The team split the game so that:

  • The main thread handles animation and transitions.
  • A web worker handles all purely computational game logic.

That change didn't speed things up in absolute terms. In the off-main-thread build, a UI update took twelve seconds instead of six. But those twelve seconds were spent shipping frames rather than sitting idle, giving users continuous feedback and letting them keep playing as the game updated. Perceived performance improved even as raw latency increased:

UI response time in the non-OMT version of PROXX.

UI response time in the OMT version of PROXX.

This is a deliberate tradeoff. An off-main-thread app gives users on constrained hardware an experience that feels responsive without making things worse for users on capable hardware.

What off-main-thread architecture really buys you

OMT doesn't make the app faster, because you're relocating work rather than reducing it. Communication between worker and main thread can even add minor overhead. But an idle main thread can handle scrolling and input while a worker is busy, which translates into fewer dropped frames. Dropped frames are harshly penalized by human perception—they register in milliseconds—whereas users tolerate wait times of hundreds of milliseconds before noticing a delay.

That distinction matters because device performance varies hugely, and you cannot reliably predict what any given user's main thread will look like. OMT is therefore less about squeezing out parallel performance gains and more about reducing risk: it makes the app behave more predictably under a wide set of runtime conditions.

Bundler support requires plugins

Tooling adoption trails the web worker API by a wide margin. webpack and Rollup don't handle workers directly, while Parcel does. Plugins fill the gap for the other two:

Bottom line

As the web reaches a global market of increasingly diverse devices, serving constrained hardware isn't an edge case; it's how a growing share of the audience connects. OMT improves the experience on those devices without penalizing users on better hardware. The secondary gains are also worth claiming: offloading heavy scripts shifts the parsing cost to a separate thread, which can speed up the boot path enough to help metrics like First Contentful Paint and Time to Interactive, with the possibility of a better Lighthouse score as a result.

Web workers don't have to be awkward. Comlink removes the messy parts of postMessage and makes an off-main-thread split a realistic option for a broad set of applications.