Utilizing the power of Web Workers

Aleksandar Radeski
Codeart
Published in
14 min readMay 4, 2023

The current scene of web application development is a race for performance and developer experience without sacrificing the core values of reliability, security, scalability, and the possibility for continuous growth and improvement. That being said, at the frontlines of web applications stand The Front-Ends, with all their glittery animations, transitions & theatrical exhibitions while fulfilling the client’s request.

Be warned! The client can be a very unpredictable creature and oftentimes request the impossible. As the complexity of software is on the rise (looking at you artificial intelligence & Web3), more common forms of software and development such as Front-End try to keep up the pace to deliver better, faster & prettier than ever.

As the demand for better software is greater than ever, web developers can find themselves confined by the limitations posed by the single-threaded nature of the JavaScript language. We’ll cover the meaning behind that in short and focus more on the possible solutions that Web Workers offer in greater detail.

1. The Problem

At the center of every website or web application interface is JavaScript, a language that operates on a single thread. Behind the scenes, a web application consists of multiple processes and each process has a task for that one single thread that it needs to be done ASAP. The processes consist mainly (but are not limited to) of responding accordingly to user interaction, updating DOM elements, taking care of transitions/animations, network communication, etc. Depending on the scope and type of the application each of these processes can turn out to be a heavy load for the thread, for example, if we have too many updates or changes to DOM elements happening at the same time. If the thread is overloaded or ‘busy’ with a complicated task for too long, it means it’s being blocked for the time being. While the thread is blocked, it cannot respond to anything, including user interactions.

A blocked thread on a Front-End application or website is bad from every point of view. Take the user experience angle for instance, if we’re lucky enough and the browser tab still hasn’t crashed, the site will be completely unresponsive, and even the most simple tasks as CSS hover styles won’t work. Every one of those tasks executes & runs in sequential order (one after the other).

Visual representation of JavaScript’s sequential execution

As you can see from the illustration, each task reserves its space on the JavaScript thread and no two tasks can use the thread simultaneously. The good thing is most of the tasks performed on the thread (such as DOM updates, and JavaScript-based animations/transitions) that are related to the UI only take a fraction of a second, and “the thread block” is not noticeable by the users.

The problem arises when we have heavy tasks that take up a lot of time and processing power to finish. Consider the following illustration.

Visual representation of a heavier task on the JavaScript thread

Let’s imagine the smaller orange chunks are some basic actions that are performed on the JavaScript thread, for example, a function is called that rotates an image by 1 degree. The large orange chunk represents some heavy process, for example, a loop cycle with 100.000 iterations.

The rotator function takes a fraction of a second to finish its job and moves on to the next task. The next task is the same rotator function and takes another fraction of a second to finish before moving on. At this point both functions have been executed, and finished their work, and not even a second has passed, that’s how fast basic and minimal tasks are finished. The third orange chunk represents the long-running loop and takes a great time to finish its work. While the loop is active and going through those 100.000 iterations, everything else is on hold and the thread is blocked. If the long task takes up to 10 seconds to finish, that is 10 seconds of an unresponsive website that will yield no interaction feedback to the user. Depending on the weight of the task, that unresponsive time can expand indefinitely.

Now imagine we have to do some heavier computation or a larger chunk of data processing. The user might as well go start crunching those numbers by himself. While the thread is busy with that computation, everything else is frozen.

The good thing is oftentimes the heavy computations and data processing is handled by the backend as it’s more capable of multitasking and the workload is offloaded on multiple threads with higher processing power, while the Front-End is explicitly for user interaction & presentation.

And that’s how it should be, but there are cases when we’re confined to the browser environment and its single Jack of all trades employee — the browser thread, and we have to make do.

We’ll go over a more visual example of the thread block, so you can fully grasp the core of the issue.

Here’s an example showcasing the unresponsiveness of the website while the thread is busy.

We have two simple divs, one of them (blue with red text) has a hover effect that changes the background color when a user hovers over them, and the other one is rotated using a simple JavaScript code.

Example of an ongoing task & user interaction

Here’s what the code for the rotator function looks like.

Next, we’ll add two buttons that call a function that initiates a loop cycle counter with 5.000.000.000 iterations that displays a pop-up alert when it’s finished. The first button will let the main thread handle the iterations & the second button will offload the task to a worker thread.

Let’s first take a look at the process while it’s being handled by the main thread. Take note of the rotating element & the unresponsive hover effect and you’ll see the side effects of a blocked thread.

Visual representation of a blocked JavaSscript thread

This is the root of the issue we’re trying to find a workaround for. Notice that even simple CSS hover effects are unavailable while the thread is busy with a task.

2. The Solution

Enter Web Workers. Web Workers are a feature that introduces the multi-threaded paradigm in JavaScript environments such as the browser or Node.js and basically solves the problems posed by the single-threaded nature of the language. It allows developers to spawn background threads that are user-designated for handling heavier tasks and thus offloading the weight off the main thread. By implementing this approach, we leave the main thread fully focused on the user interface and its updates, while the workers (background threads) do all the heavy lifting behind the scenes away from prying eyes. The web worker threads still live in the browser environment and use part of the processing power allocated to the browser, but their heavy task in no way reflects on the performance of the UI aspect of the site or the main thread which is designated for site responsiveness & healthy status.

Let’s see the previous loop example in action, this time handled inside a worker.

To keep track of active JavaScript threads, you can open up the developer console, select the ‘Memory’ tab and you’ll see all the currently alive threads.

Visual representation of a ‘live’ Web Worker in action

Behold the power of Web Workers. As soon as the button is pressed the worker comes to life to fulfill its heavy task, leaving the main thread and the user interface fully functional and responsive.

3. The How-To’s

First, we’ll go over how web workers work in theory, then continue to the code-along part.

The different threads communicate with each other using Message Events and Message Events ONLY. The Message Event object prototype contains many methods, but we’ll mostly be dealing with the data property which contains the message content. You might be familiar with message events if you’ve ever worked with Web Sockets since the communication between sockets and the communication between the main thread and the worker is very similar. The approach is as follows:

One party emits (sends) a message to a second party that is listening for any message events directed at it and responds to that message accordingly. In our case the worker is listening to message events and once the main thread emits a message to the worker it triggers a task inside the worker. Then, when the worker is finished with the task, we emit a message event from the worker to the main thread, notifying the main thread that the task is finished & sending any data along with it if needed. Both the main thread and worker thread core concepts are the message event emitters and message event listeners. This will be our way to notify the worker that it has some job to do and the details about the job.

Message Event exchange snippet

In the above example, we emit a “Hello” message from the main thread to the worker, the worker catches our message and as a response, it appends “world!” to the received message and sends it back to the main thread. Then we just console.log() the data we receive from the worker.

That covers the core concepts of the relations between two threads. Now let’s get some coding done. We’ll go step-by-step recreating the loop counter inside a worker.

We start by creating a new file called looper.worker.js, right next to our main.js file.

Basic & minimal file structure

The process in the main thread is the following:

The minimal code in our main thread
  1. In our main.js file, we select the button that should initiate the job using querySelector.
  2. We append a 'click’ event listener that initiates the worker inside the callback
  3. We send a message to the worker using the postMessage() method
  4. We add a listener to listen for worker messages & respond to them accordingly
  5. After receiving a message from the worker, show an alert pop-up containing the worker’s message & call the terminate() method on the worker ending its lifespan

The steps in the worker thread are the following:

The minimal code in our worker file
  1. We open the onmessage listener that will encompass our whole worker logic and grab the passed message event
  2. We open a switch statement that responds to the 'start_loop’ message (we can handle multiple different computations with a single worker if we categorize the tasks inside to be dependent on a received message parameter)
  3. We start the loop with 5.000.000.000 iterations
  4. Once the loop is finished, we send a message back to the main thread using the postMessage() method

The main tools you’ll need to keep in mind are the methods that the worker instance exposes. The most important ones are

onmessage — adds a message event listener to/from the worker

postMessage — emits a message to/from the worker

terminate — only callable outside of the worker, in the same scope of the worker instance, and used to kill the worker

close — only callable inside the worker, used to kill the worker from the inside, usually called after the worker finishes its job and posts it to the main thread OR inside a try/catch to avoid leaving zombie threads in the background upon any errors

Congratulations, you just finished your first successful implementation of a web worker. In the next chapter, we’ll go over the biggest benefits of web workers as well as common use cases.

4. Benefits & Use Cases

The example in the previous chapter is just one case of offloading the workload onto a different thread. This same approach can be followed for different types of workloads. In a few of our projects at Codeart, we’ve utilized the power of web workers to handle PDF document generation on the Front-End. The mission was to generate a PDF document, from API-delivered data, resulting in a few hundred pages of data. The data was initially in JSON format, so keep in mind we’d also be designated with the conversion of JSON data into table or chart data structures and then using a library to generate a PDF document from the converted data. Theoretically, everything was set up and there were no hiccups, but as soon as the data being processed reached proportions too big for the main thread, our whole app was frozen and unresponsive during the PDF generation process. That was the first real-world problem that we turned to Web Workers in hopes of solving it. And “solve it” it did, the worker was able to generate documents spanning thousands of pages in a matter of seconds while the UI was unaffected, even running from a mobile device!

The main benefits of the worker approach can be boiled down to the following:

  1. Improved performance
  2. Improved user experience
  3. Separation of concerns
  4. Parallel processing / Multitasking

A few more real-world use cases include:

Data processing

Reordering, filtering, mapping and any other type of intensive data processing or manipulation you can do in the main thread, you can offload to a worker. This includes repetitive tasks such as loop cycles, asynchronous operations & other operations where time is of the essence.

Image, Audio and Video processing

Native media files such as Images, Audio files and Video files commonly weigh a lot more than your usual .js files and when dealing with media file manipulation or processing you can imagine how great the workforce needs to be to get the job done. Offloading the job to a worker thread leaves the UI responsive, while behind the scenes the only focus of the web worker is its designated processing job. In this category of real use cases, we can mention file uploads, video & audio conferencing, or live streaming.

Real-time collaboration

Web workers would be the ideal go-to approach for creating real-time collaboration applications that support multiple users editing the same document at the same time. Take Google Docs for example, one Word document can be edited at the same time by multiple users while the document state is being constantly synchronized among those users. The part that handles the changes and synchronization between users can be offloaded to a web worker, so the document is always responsive, visible to other users and remains in sync.

Machine Learning

We can run machine learning models inside a web worker and handle the real-time processing of data. Imagine we’re working with machine learning models that deal with face or object recognition, we just supply the said worker with the input element and wait for its response while the UI is unaffected by the complex processing.

Rendering 3D graphics

The process of rendering and animating 3D graphics inside a browser can oftentimes take a lot of processing thread power and can be very resource-intensive depending on the usage and the scope of the animations. For example, we’ll take Three.js or the WebGL API, most commonly used for 3D web design, interactive web-based games, and 3D data visualization. When the rendering is triggered on user input (for example a web-based game) and it’s supposed to react to a lot of user input & trigger a lot of changes to the 3D model it can leave the UI thread unusable until a certain draw step is finished. This type of processing can be offloaded in a worker, thus maintaining the usability of the UI while a complex task is being worked on.

5. Limitations

Although I’ve made Web Workers sound more like Miracle Workers in our case with the problems caused by the single-threaded nature of JavaScript, they do come with their own set of limitations and confinements that need to be taken into account when deciding whether using a worker is the right approach.

Limited DOM access

The main constraints of web workers lie in their inability to access the DOM that is housed on the main thread. This means we can not check the state of DOM elements, call their methods or perform any manipulations on them. The worker is completely cut off from the Document Object Model and its properties. Keep in mind that while working with Front-End frameworks such as React, we are also limited in the spectrum of libraries that we can use inside our workers. If a library depends on or performs any actions on the DOM, or uses anything from the Document prototype, they are innately unusable in the worker. Other features inaccessible inside the workers are the localStorage and the cookies.

Message event-driven communication

As we concluded that threads communicate with each other only by sending and receiving message events, it’s wise to mention that data transfers need to be carefully managed to ensure data consistency. If we’re working with one chunk of data, we need to keep in mind the changes on that data and implement proper synchronization so we don’t end up with two different versions. Since it’s two different threads working separately on the same chunk of data, we need to constantly keep in mind the context and side effects of each change. As the biggest downfall of message event communication, it can prove to be a hellish experience when working with files or object-oriented principles. The message event itself has limitations as to what you can and cannot include in a message. For example, if you try to send an object containing methods from the main thread to the worker, the prototype of that object and all its methods will be lost and inaccessible in the worker thread. It’s also a steep learning curve to learn the procedure of transferring files between threads.

Increased code complexity

Using web workers can dramatically increase code complexity and it can turn out to be a proper hell when it comes to managing shared data or keeping threads in constant synchronization. There is a right time and task for a web worker and it’s an experienced developer’s job to recognize it as such.

Memory-Leak prone

Web workers are especially prone to memory leaks since it doesn’t instantly become evident we have a memory leak problem. If we have a memory leak in our main thread, it will become evident instantly by the thread being blocked and the UI unresponsive, but if it happens inside a worker we won’t get any visual cue. If a worker instance is not handled properly and ends in an infinite task, it will continue to run without causing any evident issues in the main thread, while still wasting resources in the background. This is why every worker instance needs to be properly handled and guarded against incidental memory leaks.

6. Conclusion

Despite being highly useful, providing performance benefits, and boasting excellent browser compatibility at 98%, web workers have not gained widespread attention and implementation due to their specific purpose. As previously mentioned, the majority of processing-intensive tasks are typically handled by backend servers, with only a few high-demand processing tasks left on the Front-End. However, although rare, there are still some processing-intensive tasks on the Front-End that could benefit from leveraging the power of web workers.

By utilizing the power of web workers, developers can achieve better scalability and performance, as workloads can be distributed across multiple threads, making parallel processing possible in a JavaScript-based environment. By adding web workers to their toolbox, developers can gain access to the invaluable features that come with parallel processing.

--

--