Skip to content
Core Web Vitals specialists
Core Web Vitals & measurement

Web Workers: Offload the Main Thread to Improve INP

Offloading compute-heavy JavaScript to a web worker keeps the main thread free, lowers Total Blocking Time and brings INP under the 200 ms threshold.

12 min read Web WorkerMain-ThreadINPTotal Blocking TimeJavaScript

A website can load in a flash and still feel sluggish. The reason almost always sits in the same place: the main thread. On this single strand the browser handles nearly everything JavaScript asks of it -- and as long as it is busy with a calculation, it cannot react to anything else. If a visitor types, clicks or scrolls at that moment, the input has to wait. This exact wait is what Interaction to Next Paint, or INP, measures -- the metric that replaced First Input Delay as a Core Web Vital on March 12, 2024 (web.dev). The most effective lever against an overloaded main strand is not to run compute-heavy work there in the first place, but to offload it to a web worker -- a second thread that computes in the background while the main thread stays free and answers input immediately. This article shows which tasks are worth moving, how the offloading works technically, and how to prove the gain in INP and Total Blocking Time. In our Core Web Vitals optimization the question of the main thread always sits high on the list when a page responds slowly to input.

Key takeaways

  • The browser runs JavaScript on a single main thread. From 50 ms (web.dev) on, a job counts as a long task, and for that span the main strand is blocked -- if a visitor clicks exactly then, their input has to wait.
  • A web worker is a second thread: it receives data via postMessage, computes in the background and reports back only the result. It has no access to the DOM or window, so the rule is computation in the worker, presentation on the main thread.
  • Good candidates are filters over large lists, price and discount calculations, parsing large JSON data and batching analytics events. Tasks under 50 ms (web.dev) are not worth it, because starting the worker and copying data outweigh the benefit.
  • A good INP is under 200 ms (web.dev), with at least 75 percent (web.dev) of interactions staying below that mark. 77 percent (Web Almanac 2025) of mobile sites already reach this value.
  • In the lab, Total Blocking Time serves as the proxy: it sums the time above 50 ms for each long task and accounts for roughly 30 percent (DebugBear) of the Lighthouse performance score. Measure first, then offload one task deliberately.

Why the Main Thread Becomes the Bottleneck

By default the browser runs JavaScript single-threaded: there is exactly one main thread that executes scripts, calculates layout, paints the page and reacts to clicks, keystrokes and scrolling. All of that shares the same queue. While one function runs, nothing else can happen -- the browser works through one job after another. As long as the individual tasks are short, no one notices. It becomes a problem the moment a single task runs too long at a stretch. From a duration of 50 ms (web.dev) on it counts as a long task, and for that span the main strand is blocked. If a visitor clicks exactly then, the response is delayed by precisely the task's remaining runtime.

In modern frontends such long tasks pile up quickly. A product filter iterating over thousands of items, a price and discount calculation in the cart, parsing a large JSON response, preparing analytics data, or a bulky script that initializes everything at once on load -- each of these can hold the main thread for hundreds of milliseconds. The user experiences this as a jerky interface: a click on a filter only takes effect after a noticeable pause, the menu opens with a delay, the input field accepts characters haltingly. How to systematically track down the most harmful of these long tasks is covered in the article on Long Animation Frames as INP bottlenecks; here the focus is the remedy -- taking the work off the main strand.

What Converges on the Main Thread

The main thread is the only place where the browser may change the visible interface: it executes page JavaScript, calculates layout, paints pixels and processes every user interaction. Because all of these are handled one after another, a bottleneck appears as soon as one of them takes too long. A web worker creates a second thread beside it -- but without access to the interface. It is therefore suited to pure computation that does not need to touch the DOM.

INP and Total Blocking Time as the Lab Proxy

INP measures in the field how quickly a page reacts to interactions. The value captures the delay between an input and the next visible paint and reports, across a session, the slowest significant interaction. A good INP is under 200 ms (web.dev); between 200 and 500 ms (web.dev) it needs improvement, and above 500 ms (web.dev) it is poor. For a good grade at least 75 percent (web.dev) of interactions must stay under the 200 ms mark. According to the Web Almanac, 77 percent (Web Almanac 2025) of mobile sites currently reach a good INP -- better than LCP, where only 62 percent (Web Almanac 2025) pass, but the remaining 23 percent (Web Almanac 2025) show that responsiveness is still a common problem.

INP is a field value and cannot be measured directly in the lab, because it needs real interactions. The lab proxy is Total Blocking Time, or TBT. For each long task it sums the time above the 50 ms threshold and thus describes how heavily the main thread was blocked during loading. TBT is considered the best lab proxy for INP and accounts for roughly 30 percent (DebugBear) of the Lighthouse performance score. Good is under 200 ms (web.dev); between 200 and 600 ms (web.dev) it needs improvement. Because both values share the same cause -- a main strand occupied too long -- every offloaded computation usually lowers both at once. How to read and prioritize the diagnostic report is shown in our article on reading the Lighthouse report.

What a Web Worker Is and Can Do

A web worker is an additional JavaScript thread that runs in parallel to the main thread. It is started from its own script file and communicates with the page exclusively through messages: the main thread sends data into the worker via postMessage, the worker computes and sends the result back via postMessage. While the worker runs, the main strand stays completely free -- it can accept clicks, open the menu and repaint the page without waiting for the calculation. Only once the result arrives does the main thread update the interface. From the user's point of view the page stays smooth throughout, even while an expensive computation runs in the background.

The price for this is a strict separation. A worker has no access to the DOM, to the window object or to many browser interfaces. It therefore cannot change elements, set classes or put anything directly on screen. This is not a flaw but the core of the concept: only the main thread may touch the interface, because otherwise two threads would paint the same picture at the same time. In practice this means a clear division of labor -- the worker delivers data, the main thread turns it into visible changes. Unlike simply splitting long tasks, which we cover in the article on scheduler.yield and long tasks, the work here disappears from the main strand entirely instead of just being broken into smaller chunks.

Which Tasks Belong in the Worker

A web worker pays off wherever there is longer computation that does not itself need to touch the interface. The rule of thumb: anything that only takes data in and gives data out is a candidate. Anything that changes the DOM stays on the main thread. The longer a task runs and the more often it is triggered by user interaction, the greater the gain. A filter that runs over a large dataset on every click is the prime example -- it blocks at exactly the moment the user is active. Third-party scripts can also be partly moved into a worker; how to reduce their load in general is covered in the article on trimming third-party scripts.

Search and Filter

Searching and filtering large lists -- thousands of products by size, color and price -- is pure computation. In the worker it runs without the filter click stuttering noticeably.

Calculate Prices and Discounts

Recalculating tiered prices, discount rules and tax rates in the cart takes time with complex ranges. The worker returns the finished amounts while the interface stays usable.

Prepare Data and Track

Parsing large JSON responses and collecting analytics events ties up the main strand. Prepared in the background and sent in batches, it no longer competes with interaction.

The Worker Cannot See the DOM

A web worker cannot read or change elements, cannot toggle classes and cannot access window or document. Anyone who tries to touch the interface in the worker gets an error. The rule is therefore: computation in the worker, presentation on the main thread. The result is passed back by message, and only the main strand translates it into visible changes. Drawing this separation cleanly is the most important step of a successful offload.

How the Offloading Works in Code

Technically a worker is set up quickly. On the main thread it is created from a script file, receives its input data via postMessage and reports its result through an onmessage event. The following deliberately lean snippet shows the pattern using a product filter as an example: the click sends the raw data into the worker instead of filtering itself.

main.js
// Offload compute-heavy filtering to a web worker
const worker = new Worker('/js/filter.worker.js', { type: 'module' });

button.addEventListener('click', () => {
  // Send data to the background thread; the main thread stays free
  worker.postMessage({ products, filter });
});

worker.onmessage = (e) => {
  render(e.data.matches); // show the finished result
};

The actual work is done by the worker file. It receives the data, filters and sends back only the result. Because this code runs on its own thread, even an expensive filter does not block the interface:

filter.worker.js
// filter.worker.js - runs on its own thread and never blocks the interface
self.onmessage = (e) => {
  const { products, filter } = e.data;
  const matches = products.filter(matchesFilter(filter)); // the heavy work
  self.postMessage({ matches });
};

In larger applications the message communication quickly becomes hard to follow. The open-source library Comlink helps here: it wraps the exchange between main thread and worker so that worker functions can be used almost like normal function calls. One detail remains: when sending, the data is copied (structured clone), which itself costs time for very large objects. For such cases data can be handed over via Transferable or a SharedArrayBuffer instead of being copied. These finer points of frontend optimization decide whether offloading pays off even with large volumes of data.

Limits and Pitfalls

A worker is not a cure-all. Starting it costs a small baseline overhead, and every message has to be serialized and copied. For very short calculations under the 50 ms mark this overhead outweighs the benefit -- such tasks are better left on the main thread or smoothed out by other means. DOM updates also strictly belong on the main strand. The worker shines where a clearly bounded, longer computation processes pure data. The overview below classifies typical frontend tasks:

Typical TaskEffect on the Main ThreadOffload to the Worker?
Product filter over thousands of itemsLong task, blocks the clickYes -- pure computation without DOM
Price and discount calculation in the cartDelay on every changeYes -- returns only amounts
Parse large JSON or CSVBlocks until everything is processedYes -- result comes back finished
Batch analytics and tracking dataCompetes with interactionYes -- collect and send in the background
Update the DOM, set classesMust paint the interfaceNo -- only the main thread paints the page
Short calculation under 50 msBarely noticeable effectNo -- the overhead is not worth it

The Core in One Sentence

Not every calculation has to become faster -- it just has to leave the main thread when the user is waiting for an answer. A web worker moves the work to where it blocks no one.

Measure, Prioritize and Prove It

Offloading without measurement is guesswork. The first step is therefore to find the long tasks at all. The performance panel of the Chrome developer tools marks long tasks on the timeline with a red corner and shows which function occupies the main strand for how long. The Lighthouse report reports Total Blocking Time and lists the biggest culprits among the diagnostics. What counts in the end for the real experience is the field value INP, which the open-source web-vitals library provides in its attribution build together with the triggering interaction. It is important to measure in the lab with realistic throttling -- a fast office device hides exactly the blockages that hurt most on an average smartphone. How to additionally adapt the result for weak devices and networks is shown in the article on adaptive loading on slow networks.

  1. Track down long tasks -- in the performance panel or via the Lighthouse diagnostic for Total Blocking Time
  2. Check the most expensive task: does it process pure data without touching the DOM?
  3. Offload only that calculation to a web worker and pass the result back by message
  4. Keep an eye on message size -- for large objects use Transferable or a SharedArrayBuffer
  5. Measure TBT in the lab again and compare with the state before
  6. Follow the INP in the field data of real users until it stays stably under 200 ms

We do not chase every millisecond, but the one task that blocks the main strand while the user is clicking. Moving it into a worker often does more than a dozen small micro-optimizations -- and the effect is measurable in the INP of real visitors.

Project experience from 50+ performance projects

This is exactly how we proceed in a structured performance audit: name the long tasks in the field and in the lab, take the offloadable computation off the main strand, and prove the gain in Total Blocking Time and INP -- instead of shrinking scripts across the board. The bottleneck often shifts after the first change, because a different interaction then dominates; the analysis is therefore repeated until the page responds smoothly across all important interactions. Which services this includes is shown in our overview of performance services; how to secure a fixed limit for script load permanently is covered in the article on JavaScript performance budgets. Anyone who wants to carry the smooth impression into navigation itself will find the next step in the article on seamless page navigation with View Transitions.

This article is based on data from: web.dev (Interaction to Next Paint, Optimize long tasks and Total Blocking Time, Google Chrome), DebugBear (Total Blocking Time and Lighthouse weighting) and Web Almanac 2025 (HTTP Archive, Performance chapter). All statistics cited were verified at the time of publication.

Related Articles