Web Workers: Avoid overloading the main thread

tassiofront

Tássio

Posted on August 19, 2023

Web Workers: Avoid overloading the main thread

Is your application overloading the main thread? In this article, I show a practical example that might make you start using Web Worker - let's take a look at how them can avoid the main thread getting overloaded.
I use a personal project to show it - React (SPA).

The same applies to any other js frameworks.

What are Web Workers?

It requires an exclusive article to explain Web Workers, but I'm gonna try a short explanation.

First, to understand it, you must take in mind your frontend application runs in the browser's main thread: the thread where most tasks are run to make your application accessible. It's responsible to compile files, execute files, and also handle user interactions (events and input).

Everything is processed task by task, one by one. This means the main thread can't process a task to compile a JS file and treat an input event at the same time, for example.

Web Workers are a simple means for web content to run scripts in background threads. The worker thread can perform tasks without interfering with the user interface.

This is the MDN definition of Web Workers. In other words, Web Workers is a way to run js scripts in their thread, which can avoid overloading the main thread.

Project

Before getting into the practical code, let's see the project we gonna work on and what changes.

project overview

This is the current project, it fetches my Dev.to articles and show them in the view, besides it loops into the articles array and gets all tags (creating a unique array with all tags). The tags array is used to render the chips in the modal - which will be used to filter in the future.

Once said, the idea is to explore the tags array created using the main thread only VS also using a Web worker.

Note: It's not a React article, so I won't focus and the whole view code, but the source code is in the final.

Let's see how it will be processed:



// Articles.tsx (the view component)
//...more code

const handleTags = () => {
 const map = new Map();
 // articles is my Dev.to articles
 for (const article of articles) {
  for (const tag of article.tag_list) {
    map.set(tag, tag);
  }
 }
// set the tags state
 setTags(Array.from(map.values()));
}

// call handleTags when articles GET has finished
useEffect(() => {
    !isLoading && handleTags();
}, [isLoading]);

//...more code


Enter fullscreen mode Exit fullscreen mode

When the Articles view is rendering, It loops into the articles array and the tag_list array then set each item into the map. Then, gets the map object and converts an array. We see it in the modal:

tags array listed

As you can see, this code has an O nˆ2 complexity, as there is a for loop inside another for loop. To make it more interesting and easier to see the difference, let's put an extra for:



// Articles.tsx (the view component)
//...more code

const handleTags = () => {
 const map = new Map();
 // articles is my Dev.to articles
 for (const article of articles) {
  for (const tag of article.tag_list) {
    map.set(tag, tag);
  }
 }
// extra code, huge operation
for (let idx = 0; idx < 1000000000; idx++) {
  idx++;
}
// set the tags state
 setTags(Array.from(map.values()));
}

// call handleTags when articles GET has finished
useEffect(() => {
    !isLoading && handleTags();
}, [isLoading]);

//...more code


Enter fullscreen mode Exit fullscreen mode

This code will help us understand the difference between use and don't Web Workers.

How to measure the impact of using Web Workers

First, I put more elements in the view to see:

  1. A "working..." text to see while the handleTags is processing;
  2. A checkbox to try to click while the handleTags is processing;

The second it's the most important, the idea is to show how the main thread does not do more than one task at the same time, later.

more elements in the page

Second, I will use the browser's performance tab to detect how the handleTags function takes a long, besides simulating a slower CPU.

the browser's performance tab to detect how the handleTags function takes a long, besides simulating a slower CPU

Ok, now we're ready to compare the execution of the handleTags on the main thread only VS using Web Worker.

Using the main thread only

So, first, I tried to click on the checkbox element while the handleTags was processing.

trying to click on the checkbox element while the handleTags was processing

Do you see it? I'm trying to click on the checkbox, but the element keeps unchecked. That shows us what happens when a user tries to do something while the main tread is busy.

Note: if you wanna try it by yourself, I put the source code in the final with the instructions.

Looking in the performance tab, we can see a long task shown.

handleTags is long task to the browser - executed on the main tread

Two seconds were needed for the main thread to execute it. In other words, the user couldn't do anything for 2 seconds.


Enjoying it? If so, don't forget to give a ❤️_ and follow me to keep updated. Then, I'll continue creating more content like this_


Using the main thread and a Web Worker thread

As Web Workers have their thread (and they don't have access to the DOM), we must create a communication between the main thread and the Web Worker thread. This way we can use the Web Worker thread when required only.

First, let's create a new file that will be used to create a Web Worker:



// sw.ts file
import { IArticle } from '@/models/Article';

// self is like the globalThis, but for Web Workers (there is no global window on the Web Worker thread)
// onmessage is the callback executed when the main thread asks for a Web Worker execution

self.onmessage = (e: MessageEvent<IArticle[]>) => {
  const map = new Map();
 // e.data is my Dev.to articles
 for (const article of e.data) {
  for (const tag of article.tag_list) {
    map.set(tag, tag);
  }
 }
// extra code, huge operation
for (let idx = 0; idx < 1000000000; idx++) {
  idx++;
}

// Once execution has finished, Web Worked sends a message to the main thread (hey, I'm done. Get the result).
 self.postMessage(Array.from(map.values()));
};



Enter fullscreen mode Exit fullscreen mode

So, we moved the code from the handleTags to a separate file, using the Web Worker syntax.

The code is now on the onmessage callback (which is executed when the main thread asks for a Web Worker execution), then we send back the result to the main's thread. But how about the component?



// Articles.tsx (the view component)
//...more code

// creating a Web Worker instance. It asks for a path file (our sw.ts in this case)
const swArticles: Worker = useMemo(
 () => new Worker(new URL('./sw.ts', import.meta.url)),
 []
);

const handleTags = () => {
// handleTags there is no more looping, but it's responsible for asking a Web Worker execution outside the main thread

 if (window.Worker) { // validate if there is Worker on the window
   // hey Web Worker thread, do something to me with this data
    swArticles.postMessage(articles);
  }
}

// call handleTags when articles GET has finished
useEffect(() => {
    !isLoading && handleTags();
}, [isLoading]);

// when Web Worker sends the result
 useEffect(() => {
    if (window.Worker) {
      swArticles.onmessage = (e: MessageEvent<IArticle['tag_list']>) => {
// set the tags state
        setTags(e.data);
      };
    }
  }, [swArticles]);

//...more code


Enter fullscreen mode Exit fullscreen mode

Ok, now our code changed a bit:

First, it creates a Web Worker instance, passing the sw.ts path on the constructor. This is stored on the swArticles state, which is a Web Worker instance now.

When the articles GET has finished, it calls the handleTags, which now there is just a postMessage from swArticles. This is enough to process something on the Web Worker thread - the code on the sw.ts file

When the Web Worker finishes (self.postMessage), it sends back the result, which is listened to, and changes the tags state by calling setTags.

Let's see our view behavior now:

View using web worker

Great! Now, it's possible to click on the checkbox element even when the handleTags is executed. The main thread says thanks!

how about the performance tab?

using Web Worker, the task is no longer any more

Now handleTags takes 0.13ms!!!

Wait!!! What happens with the loops?

Great question! On the performance tab, below the main thread graph, we see the Worker thread:

Web Worker thread

This means the Web Worker has done the heavy job.

Try yourself

You can simulate both cases by cloning the repo, then changing to the feat/main-thread-vs-web-workers branch. After that, running the project you might have different behaviors by passing a query parameter:

Try it and see the difference!

Buy me a coffee ☕. Hope I have helped you somehow. 🤗

Conclusion

In this article, we saw the Web Worker power a bit and how it helps to avoid overloading the main thread. This is a teaching example, but for sure it's something we must keep in mind as FrontEnd developers. Explore it!

See my other articles

💖 💪 🙅 🚩
tassiofront
Tássio

Posted on August 19, 2023

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related