Web Workers: Avoid overloading the main thread
Tássio
Posted on August 19, 2023
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.
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
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:
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
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:
- A "working..." text to see while the handleTags is processing;
- 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.
Second, I will use 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.
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.
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()));
};
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
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:
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?
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:
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:
- Normal behavior: it uses Web Worker but has not the checkbox element;
- Passing sw=on (http://localhost:3000/articles?sw=on) query parameter: it uses Web Worker and has the checkbox element;
- Passing sw=off (http://localhost:3000/articles?sw=off) query parameter: it does not use Web Worker and has the checkbox element;
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!
Posted on August 19, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.