Home / Articles

Keeping the Page Responsive: When to Move CPU Work into a Web Worker

Learn why heavy loops freeze the browser UI, how the Web Worker message pattern offloads that work, and how to decide whether a task deserves a Worker at all.

Publisher: REACTAPP.TOP12 min read2703 words

This article is published in English.

Sources: JavaScript Workers: How to Run Code Without Freezing Your UI

A user presses a button, the label switches to "Processing…", and then nothing responds: animations stall, scrolling sticks, further clicks are ignored. The tab has not crashed; a piece of JavaScript is simply monopolising the main thread. This guide explains why that happens, shows the basic Web Worker pattern for moving expensive computation elsewhere, and gives you a practical way to decide when a Worker is the right fix and when it only adds moving parts.

Why a single long task freezes everything

The browser as a whole does far more than execute your scripts, but the JavaScript that belongs to the page runs on one main thread. If you want the bigger picture of how that thread fits into the browser's multi-process design, see JavaScript's single thread vs the browser's multi-process engine. The practical consequence is simple: that thread can become a bottleneck.

Take a function that sums every integer below one billion. Nothing about it is wrong, and the engine will happily run it to completion:

function calculate() {
  let total = 0;
  for (let i = 0; i < 1_000_000_000; i++) {
      total += i;
    }
    return total;
  }
calculate();

The problem is what happens meanwhile. As long as the loop is spinning, the main thread cannot pick up anything else that depends on it. Click handlers, input processing and much of the work of updating what is on screen all wait in line for the same thread. That queueing is exactly what a user perceives as a frozen interface.

The chain of events looks like this:

User clicks button
       ↓
JavaScript starts heavy calculation
       ↓
Main thread stays busy
       ↓
UI work has to wait
       ↓
Page feels frozen

Nothing is broken here. Your code is holding the thread for too long, and the browser is being fair by letting it finish. This is also why code that seemed perfectly fine in development can turn into a real performance issue in production: the function has not changed, but the dataset or the amount of computation it has to chew through has grown.

Giving heavy work its own execution context

A Web Worker is a separate JavaScript execution context that runs independently of the page's main script. Instead of piling everything onto one thread:

MAIN THREAD
     │
     ├── UI
     ├── Events
     ├── JavaScript
     └── Heavy calculation  ← problem

you split responsibilities so the page keeps UI, input and page logic while the Worker takes the expensive computation:

MAIN THREAD                  WORKER
     │                         │
     ├── UI                    ├── Heavy calculation
     ├── User input            ├── Data processing
     └── Page logic            └── Result
             │                  │
             └── messages ─────┘

The line connecting the two sides in that diagram is the key detail. A Worker does not share your page's variables or objects. The two sides exchange messages, and that constraint shapes everything else about working with Workers.

A first Worker, step by step

Here is the summing loop from earlier moved off the main thread. The page script creates the Worker from a separate file, sends it the upper bound, and registers a handler for the answer:

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

worker.postMessage(1_000_000_000);

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

The Worker file listens for incoming messages, performs the loop using the number it was given, and posts the total back:

self.onmessage = (event) => {
  let total = 0;
  for (let i = 0; i < event.data; i++) {
    total += i;
  }
  self.postMessage(total);
};

It helps to trace the round trip one call at a time. The page hands the Worker its input with postMessage:

worker.postMessage(...)

Inside the Worker, self refers to the Worker's global scope, and its onmessage handler is where the input arrives:

self.onmessage = (...)

Once the calculation finishes, the Worker replies through its own postMessage:

self.postMessage(...)

Finally, the page's handler on the Worker object receives that reply as event.data:

worker.onmessage = (...)

Notice that assigning onmessage after calling postMessage in the page script is fine: the reply can only arrive asynchronously, after the current script has finished running, so the handler is always in place in time.

What changes, and what does not

A useful way to picture it is delegation. The main thread effectively says "here is the data, you deal with it", the Worker grinds through the job, and it reports back when done. Without a Worker, the flow is strictly sequential and the UI waits:

Main thread
     ↓
Heavy calculation
     ↓
UI waits

With one, the main thread hands off the job and carries on serving the page while the calculation proceeds in parallel:

Main thread ──→ Worker
     │             │
     │             └── Heavy calculation
     │
     └── continues handling the page

Be clear about what you gain. Moving work into a Worker does not generally make that work finish sooner. The loop takes roughly as long as before, and the message round trip adds a little on top. What you win is that the calculation no longer competes with the main thread for time, so the page stays interactive while it runs.

Talking to a Worker: messages, not the DOM

The biggest mental shift is that a Worker cannot reach into the page. There is no document in a Worker's global scope, so a call like this will fail there:

document.querySelector("#result");

Everything that crosses the boundary goes through messages. You send data in with:

worker.postMessage(data);

and you listen for what comes back with:

worker.onmessage = (event) => {
  console.log(event.data);
};

This restriction turns out to be a feature. Because the Worker can only receive input and emit output, the data flow between the two sides is always explicit:

Main Thread
     │
     │ postMessage()
     ▼
  Worker
     │
     │ postMessage()
     ▼
Main Thread

For most applications, a narrow, message-shaped boundary between "the part that renders" and "the part that computes" is exactly the design you would want anyway. The Worker never touches the DOM, and the page decides what to do with each result.

Refactoring a blocking click handler

The typical starting point is a click handler that crunches a big dataset synchronously and renders the output right away:

button.addEventListener("click", () => {
  const result = processHugeDataset(data);
  renderResult(result);
});

If processHugeDataset() runs for a noticeable amount of time, the main thread is tied up for all of it, and the page stops responding until it returns.

The Worker-based version splits the handler in two. The click only posts the data; rendering happens later, when the result message arrives:

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

button.addEventListener("click", () => {
  worker.postMessage(data);
});

worker.onmessage = (event) => {
  renderResult(event.data);
};

The expensive processing now happens inside the Worker, and the main thread is free to keep handling the page while it waits. Two things are worth keeping in mind with this shape. First, the Worker is created once, outside the handler, so repeated clicks reuse it instead of spawning a fresh one each time. Second, if a user can click several times quickly, results may arrive after the user has moved on; in real code you usually tag each message with an id or ignore stale replies so an old result does not overwrite a newer one.

Deciding what belongs in a Worker

It is easy to get enthusiastic and start pushing every function into a Worker. That is rarely a good idea. A Worker earns its place when the task is CPU-heavy enough to fight with the main thread for time. Typical candidates include:

  • Processing or aggregating large datasets
  • Complex numeric or algorithmic calculations
  • Large JSON transformations
  • Parsing big files
  • Image manipulation
  • Audio or video processing
  • Compression and other data transformations
  • Certain cryptographic or mathematical routines
  • Syntax highlighting and other costly text processing

The recurring profile is the same: lots of computation and little need to touch the DOM directly.

When a Worker is overkill

For something as trivial as multiplying two numbers, a Worker is pure overhead:

const total = price * quantity;

Spinning up a Worker has a startup cost, every message carries communication overhead, and you now own another file with its own lifecycle. For small operations, plain JavaScript on the main thread is the better choice. Instead of asking whether you could use a Worker, ask whether the work is heavy enough that leaving it on the main thread actually hurts the user experience. That question filters out most of the cases where a Worker would only add complexity.

The architectural side effect: a clean boundary

Workers are not only a performance tool. They also impose a separation of concerns. Your UI code can concentrate on turning input into state and state into display:

Input
↓
State
↓
Display

while the Worker concentrates on turning input data into output data:

Input data
↓
Processing
↓
Output data

Keeping expensive processing isolated like this makes it easier to test and reason about, because the Worker behaves like a pure function behind a message interface.

The trade-off is real, though. You now have to manage message-based async flow, when Workers start and stop, how failures surface, and how data moves across the boundary. That is a reasonable price when the work is heavy. Good engineering is not about removing complexity everywhere; it is about placing it where it pays for itself.

Dedicated, Shared and Service Workers are different tools

"Web Worker" is often used as if it named a single API, but several Worker-related APIs exist and they serve different purposes:

  • Dedicated Worker: runs JavaScript on behalf of one particular page or context. This is the type used throughout this guide.
  • Shared Worker: a single Worker that multiple browsing contexts (for example, several tabs of the same origin) can connect to and communicate through.
  • Service Worker: sits between the page and the network to enable features such as request interception, caching and offline support.

For offloading CPU-bound work, the Dedicated Worker is the one to understand first. A Service Worker is not a drop-in substitute for it; the two solve different problems. For a deeper comparison of the three, see Dedicated, Shared and Service Workers: picking the right browser thread.

Details that matter in production

The minimal example is intentionally small. Real applications need a bit more care around failures, cleanup and data size.

Listen for errors

An uncaught exception inside the Worker surfaces on the page as an error event. Attach a handler so failures are logged instead of silently leaving the UI waiting forever for a result:

worker.onerror = (error) => {
  console.error("Worker error:", error);
};

Terminate Workers you no longer need

A Worker keeps running until it is stopped. When a Worker exists only for a one-off job, shut it down from the page once you are done:

worker.terminate();

This matters most for short-lived Workers; a long-lived Worker that serves the whole application can simply stay alive.

Data transfer is not free

Every message involves moving data between contexts. For most JavaScript values, the browser uses the structured clone algorithm, which produces a copy on the receiving side. For some large binary payloads, such as ArrayBuffer instances, you can pass them as Transferable objects, which hands ownership to the other side instead of copying; the sender can no longer use the transferred buffer afterwards. You do not need every detail of this immediately, but keep one rule in mind: taking computation off the main thread does not make shipping the data to it free. Cloning a very large object can itself cost noticeable time on the main thread.

Workers and React

React does not change how the browser schedules JavaScript. If a React event handler performs a large synchronous calculation before updating state, that calculation still runs on the page's main execution context, exactly like any other script:

function handleClick() {
  const result = expensiveCalculation();
  setResult(result);
}

No framework can make CPU-heavy JavaScript vanish. What you can do is treat a Worker as the computation layer behind your components: the UI sends data out, the Worker processes it, and the result comes back to be stored in state and rendered:

React UI
   │
   │ send data
   ▼
Web Worker
   │
   │ process data
   ▼
React UI

This pays off especially in interfaces where users work with large datasets, editors, visualisations or other computation-heavy views. The framework is not the point; the point is knowing which thread is doing the expensive work. In a component, that typically means creating the Worker in an effect, calling setResult from its message handler, and terminating it in the effect's cleanup so unmounted components do not leave Workers running.

Workers are not a general speed-up

The most common mistake is reaching for Workers as a universal performance fix. If an application is slow because of needless re-renders, too many network requests, oversized assets, inefficient React updates or tangled code, a Worker will not address any of that. Identify the actual bottleneck first, typically with the browser's performance profiler, and only then judge whether a Worker is the right remedy.

A short checklist helps:

  1. Is the task genuinely CPU-heavy?
  2. Does it run long enough to hurt responsiveness?
  3. Does it need direct access to the DOM?
  4. Can its input and output travel as messages?
  5. Is the extra complexity worth it?

When the answers point the right way (heavy, long, no DOM, message-friendly, worth it), a Worker is likely a strong fit.

It also helps to separate two problems that are easy to conflate. In the first, JavaScript is doing more work than it needs to, and the fix is a better algorithm or less work overall. In the second, the work is legitimately expensive but has no reason to block the interface; that is where a Worker shines. A Worker does not turn inefficient code into efficient code. It gives costly code a different place to run.

Key takeaways

You do not need to memorise the whole Worker API. The model that matters fits in one picture:

MAIN THREAD
     │
     │ "Here is the work."
     ▼
  WORKER
     │
     │ "Here is the result."
     ▼
MAIN THREAD
  • The main thread owns the page; any long synchronous task on it delays input handling and screen updates.
  • A Dedicated Worker runs isolated JavaScript and communicates only through postMessage and onmessage; it has no DOM access.
  • Offloading keeps the UI responsive but does not usually make the computation itself faster, and moving large data still has a cost.
  • Handle onerror, call terminate() on temporary Workers, and consider Transferable objects for big binary payloads.
  • Profile first. Optimise the algorithm when the work is wasteful; reach for a Worker when the work is necessary but does not belong on the main thread.

The next time a page locks up during a big calculation, ask not only how to make the function faster, but whether it needs to run on the main thread at all.