Why the Browser Needs a Second Thread

Every web app shares one hard constraint: the browser's main thread is a single lane of traffic. JavaScript execution, event handling, style calculation, layout, painting, and compositing all occupy that same lane, in sequence. When any one of those tasks runs long, everything behind it waits — and the user sees the result as jank or lag.

The performance targets are unforgiving. RAIL guidance calls for responding to user input within 100ms, and for maintaining a steady frame rate. With a standard 60Hz display, the browser has roughly 16.6ms to produce each frame. That budget shrinks to 11.1ms on 90Hz screens and 8.3ms on 120Hz screens, which are increasingly common on premium devices. There is no reliable API to detect the display's refresh rate; the only practical signal is measuring time between requestAnimationFrame() callbacks.

Worse, the devices your code runs on span an enormous performance range. Flagship phones improve generation over generation, while budget devices have effectively plateaued at the performance level of a 2012 iPhone. The same JavaScript that finishes in 0.5ms on a modern flagship might take 10ms on a low-end device. There is no single "correct" chunk size that keeps code within the frame budget across that spectrum.

The Limits of Yielding

JavaScript was designed to run in lock-step with the rendering loop. Long-running tasks were addressed with an asynchronicity model built on callbacks and, later, promises. The standard advice — "chunk your code" or "yield to the browser" — asks you to break work into smaller pieces and hand control back to the browser between chunks, giving the rendering loop a chance to paint a frame.

That technique has inherent weaknesses. To avoid blowing the frame budget, you must yield at least once per frame. Yielding too frequently, however, adds enough scheduling overhead that it can hurt overall performance. And for UI-related work, yielding mid-operation can paint partially complete interfaces, making layout and paint even more expensive. A proposed task scheduler API may eventually expose a cleaner primitive like await yieldToBrowser(), but the fundamental trade-offs of chunking would remain.

Web Workers: The Thread Primitive

Workers offer a way out of the lock-step model. By moving JavaScript to a separate thread, you can run long computations without the complexity and cost of chunking, and the rendering thread is unaffected. A worker is created by passing the path to a separate JavaScript file:

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

Workers belong to a family of similar-sounding but distinct technologies, and it's worth keeping them apart:

  • Web Workers (the focus here) are isolated JavaScript scopes running on separate threads, spawned and owned by a page. You cannot touch the DOM from a worker.
  • Service Workers are short-lived, isolated scopes that act as proxies for network requests from same-origin pages. They enable caching strategies, push notifications, and other background work that must run without an open page.
  • Worklets are isolated scopes with severely restricted APIs — e.g., AudioWorklet, the CSS Painting API, and the Animation Worklet — that browsers may move between threads as needed.
  • SharedWorkers let multiple same-origin tabs reference the same worker, but the API is effectively unpolyfillable and has only landed in Blink, so they remain impractical.

Because JavaScript APIs were never designed for concurrency, they are generally not thread-safe. The browser's solution is to keep workers in completely isolated scopes: no shared variables, no shared code, no access to the page's objects. Data moves only via message-passing with postMessage, which copies the payload and fires a message event on the receiving end. That isolation comes at a cost — updating UI from a worker is impossible without substantial effort, as demonstrated by projects like AMP's worker-dom.

A table taken from caniuse.com, showing that every browser supports Workers.
Web Workers are fully supported in every browser since IE10. (Large preview)

Browser support for Web Workers has been near-universal since IE10. Their adoption, however, has stayed relatively low — largely, it seems, because worker ergonomics are unfamiliar rather than because the technology is unreliable.

Two Ways To Think About Workers

Adopting Workers means rethinking how an application is structured. JavaScript offers two distinct concurrency models that both rely on Workers but take opposite approaches. Real applications typically land somewhere between the two extremes.

The Actor Model

One useful way to conceptualize Workers is through the Actor Model, popularized by languages like Erlang. Each actor—whether on a separate thread or not—fully owns the data it operates on. No other thread can touch that data, which eliminates the need for synchronization mechanisms like mutexes. Actors communicate only by sending messages to one another.

Consider a simple division of responsibility: the main thread acts as the actor owning the DOM and UI, responsible for rendering and capturing input. A second actor might own the application state. The DOM actor translates raw input events into semantic app-level events and sends them to the state actor. The state actor updates its state object, then sends a copy back to the DOM actor, which updates the DOM accordingly.

This model has drawbacks. Every message must be copied, and copy time depends on message size and device capability. In practice, postMessage is usually fast enough, but certain scenarios expose its limits. There is also a balance to strike: moving code to a Worker frees the main thread, but communication overhead and Worker busy time can still hurt UI responsiveness if not managed carefully.

The structured clone algorithm behind postMessage is quite capable—it handles circular data structures, Map, and Set. But it cannot handle functions or classes, since code cannot be shared across scopes. Posting a function throws an error, while a class is silently converted to a plain object, losing its methods in the process.

Because postMessage is fire-and-forget, request/response patterns must be built manually. That motivation led to Comlink, a library that adds an RPC layer on top of postMessage. With Comlink, objects from a Worker appear accessible from the main thread and vice versa, with the only caveat being that functions return promises rather than direct results.

Comlink still relies on postMessage underneath. When message size becomes a bottleneck—a rare case—ArrayBuffers can be transferred instead of copied. Transfer is near-instant and moves ownership: the sending scope loses access to the data. This technique proved useful for offloading physics simulations in a WebVR app.

Shared Memory Concurrency

Traditional threading relies on shared memory, but that model is largely incompatible with JavaScript since most APIs assume no concurrent object access. Instead, shared memory is confined to one dedicated type: SharedArrayBuffer, or SAB.

A SAB is a linear chunk of memory, manipulated via Typed Arrays or DataViews. When sent through postMessage, the receiver gets a handle to the same memory, not a copy—changes are visible across threads. Atomics provides utilities for atomic operations and thread-safe waiting mechanisms, allowing custom mutexes and concurrent structures.

But a SAB is just bytes—extremely low-level, offering power at the cost of engineering effort. There is no ergonomic way to work with familiar JavaScript objects. Experimental libraries like buffer-backed-object synthesize objects that persist to an underlying buffer. WebAssembly offers a more mature path to shared-memory concurrency by supporting C++ threading models, though it demands leaving JavaScript's comfort behind.

Real-World Example: PROXX

In 2019, the author's team built PROXX, a web-based Minesweeper clone targeting feature phones—devices with small screens, underpowered CPUs, and no GPU. These phones are popular due to low cost and include full web browsers, opening web access to new demographics.

To keep PROXX responsive on weak hardware, an Actor-like architecture was used. The main thread handles DOM rendering via preact and WebGL when available, plus UI event capture. The worker runs all game state and logic, including determining whether a clicked cell hides a black hole and how much of the board to reveal. The game logic even streams partial results to the UI thread for continuous visual feedback.

Beyond Responsiveness

Workers primarily serve smoothness and responsiveness, but they may also reduce battery drain. Parallel use of CPU cores can let processors avoid sustained "high performance" modes, lowering overall power draw. Explorations into web app power consumption have touched on this benefit.

Incremental Adoption Strategy

Many developers hesitate to adopt Workers because it’s unclear which code belongs off the main thread. A pragmatic approach supports gradual migration: keep only computation-heavy, DOM-free modules eligible for Worker execution. Strictly separate UI code from pure logic so fewer modules depend on browser-only APIs.

Reducing reliance on synchronous patterns makes it easier to adopt async/await and callbacks later. With that discipline, modules can be moved to a Worker via Comlink and measured for actual performance impact—positive or negative. It’s crucial to avoid assumptions and get real numbers, since browser optimizations sometimes behave counterintuitively.

For existing codebases, migration is harder. Invest time in identifying modules with DOM dependencies and refactor to remove them where possible, then incrementally shift modules into Workers.

Tooling Support

Since Workers require separate files, bundlers are often the first obstacle. Many developers resort to Data URLs or Blob URLs, but both have problems: Data URLs fail in Safari entirely, and Blob URLs lack origin and path concepts, breaking relative fetches. Modern bundlers are improving:

  • Webpack: v4 required the worker-loader plugin; v5 understands the Worker constructor natively and can share modules between threads.
  • Rollup: The author's rollup-plugin-off-main-thread enables Workers with minimal setup.
  • Parcel: Both v1 and v2 support Workers out of the box.

Module Workers

All modern browsers support JavaScript modules via <script type="module" src="file.js">, and most now also support module Workers with new Worker("./worker.js", {type: "module"}). Safari’s support is newer, so older browser compatibility matters. Fortunately, bundlers—with the plugins above—can translate module worker code to classic scripts, effectively acting as a polyfill for Module Workers.

What’s Next for Concurrency in JavaScript

Ergonomics for concurrent JavaScript remain a weak spot, even though the Actor Model fits well with the platform’s event-driven nature. A lot of tooling and libraries exist to smooth over the rough edges, but the language itself needs to improve. TC39 engineers are looking at ways to support both shared-memory and message-passing models more naturally. Several proposals are in the works, including the ability to postMessage code directly, share objects across threads, and introduce scheduler-like APIs similar to what native platforms offer.

None of these proposals are far enough along in the standardization pipeline to rely on, so they aren’t worth detailing yet. If you want to track what might land in a future version of JavaScript, the TC39 proposals repository is the place to watch.

Why Workers Matter

Workers are essential for keeping the main thread responsive and avoiding long-running tasks that block rendering. Because communication with a worker is inherently asynchronous, adopting workers forces some architectural changes in your application. The payoff is a smoother experience across the wide range of devices that access the web, from low-end phones to high-end desktops.

Design your app so that code can be moved between threads with minimal friction. That way, you can measure where off-main-thread architecture actually helps before committing to a full refactor. The learning curve for worker ergonomics is real, but libraries like Comlink can hide most of the complexity.

For more background, check out Surma’s talk “The Main Thread Is Overworked And Underpaid” from Chrome Dev Summit 2019, the guide on when to use workers, the Three.js/WebXR off-main-thread case study, and the postMessage performance notes. The Comlink library and the web-worker npm package are also worth a look. On the energy front, Microsoft’s post on green, energy-efficient PWAs covers the sustainability angle.

Common Concerns, Answered

Isn’t postMessage Slow?

Measure first. Nothing is definitively fast or slow until you test it. In practice, postMessage is usually “fast enough.” As a rule of thumb, if JSON.stringify(messagePayload) stays under 10KB, you’re unlikely to create a long frame even on slow phones. If messaging does become a bottleneck, try:

  • Breaking work into smaller pieces so each message is smaller.
  • Sending patches or diffs instead of full state objects when only parts changed.
  • Batching many small messages into one larger message.
  • Switching to numerical payloads and transferring ArrayBuffers instead of sending object-based messages.

The right choice depends on your context, so isolate the actual bottleneck first.

What If I Want DOM Access From the Worker?

That desire is common, but it usually only shifts the problem. You’d end up with a second “main thread” that has the same issues on a different thread. Making the DOM thread-safe would require locks that slow down DOM operations for everyone, which would likely hurt many existing applications.

The single-threaded, lock-step model has a real benefit: the browser knows exactly when the DOM is in a valid, renderable state. With multi-threaded access, that signal disappears, leading to potential partial renders or visual artifacts.

Do I Really Need a Separate File for Workers?

It’s a valid annoyance. TC39 is evaluating proposals that would let modules be inlined into each other without the pitfalls of Data URLs or Blob URLs. Those proposals would also make it possible to spawn a worker without a separate file. There’s no clean solution today, but future JavaScript versions will almost certainly remove this limitation.