There are many problems associated with JavaScript’s single-threaded behavior. Among them is combining long-running tasks with the UI components simultaneously. The highlighted issue can cause a huge loophole in our web app when a CPU-intensive task blocks the UI components, making the webpage freeze.
Often, developers use the setTimeout function and event-driven architectures to mimic concurrency. Even the setTimeout()
function isn't part of JavaScript features; it belongs to the environments in which the JavaScript VM is embedded, such as Node.js or browsers. They provide setTimeout()
via environment-specific APIs. This way, we are only changing a synchronous behavior to an asynchronous one. Hence, the thought of multithreaded architecture becomes highly useful, and HTML5 provides the web workers spec as an excellent alternative.
This article describes the problems with single-threaded operations and how to implement, for example, parallelism/concurrency, to alleviate these problems by using the Web Workers API.
By the way—the source code for this project is on GitHub.
Scenario: Blocking operation
Similar to the way a baby cries for help, an error pops up in our browser, and we find ourselves helpless; our webpage just froze due to a long-running task, and we have to wait till it's done. Hence, all buttons remain unclickable, and the UI is unresponsive, which results in an unwanted pop-up:
One of the flaws of a single-threaded application emerges from building a heavy application that is CPU intensive. Examples include processing large arrays, background I/O, and rendering a large sum of data from a server. When we see a function execution hindering the following function from executing for a few seconds, wait because a single thread is handling a blocking process.
Later in this article, we will create this error and fix it using what we learn here.
Single-threaded JavaScript
JavaScript VM is fundamentally designed to spawn a single thread, which means that it cannot read and execute multiple instructions simultaneously. Therefore, it must wait for the execution of a single instruction to be fully complete before moving on to the next one. This is also known as synchronous programming or blocking behavior.
Having identified the problems, let's dive into how workers can ease off our stress.
We got an extensive background I/O operation to run from the server, and this will take a few more seconds. Then, we have to devise a means to process the server actions on a different thread so that our UI is in a clean state.
The new independent thread will run the long-running action so that our main thread can have free space to execute other instructions. Otherwise, our application will become slower, resulting in a bad user experience.
What are Web Workers?
Web workers enable two or more threads in the form of two independent scripts that simultaneously perform multiple tasks. All units of execution outside the kernel are organized into processes and threads. Web workers allow us to spawn a new instance of the JavaScript engine.
Similarly, concurrency and parallelism describe the tasks we can perform with web workers;
Parallelism
Parallelism is when web workers spawn two or more tasks to co-occur at the same time using two or more CPU cores. However, web workers do not automatically provide Parallelism. Rather, it is the specification of our system hardware, by having multiple CPU cores, and the scheduler attached to the kernel must decide to run the threads on separate CPU cores. In contrast, concurrency is the process of multitasking on a single CPU core by interleaving between the tasks.
When JavaScript VM renders our animations and the UI components on its thread, a web worker instance will spawn its thread on a separate core in the background, performing other tasks that enable our application to run multiple tasks at a time, such as clicking, selecting, and calculating. Different drummers complete the task of producing a beat. Web workers can create parallel computing (by completing multiple tasks simultaneously) if the CPU and necessary hardware are available.
Image source: here
Concurrency
The implementation of web workers on a single core is similar to this scenario. The switching is very fast, so it is not noticeable, and it is partly combined with the kernel and the CPU hardware. Designing our app to be flexible enough to alternate between tasks A and B, as well as B and C, is what we are trying to achieve with concurrency. When we achieve this, our UI is free.
For concurrency, this sounds tricky, but know that the tasks are not occurring simultaneously but rather alternating the execution until the whole task is complete. Nevertheless, it enables multitasking. With the picture below, we can easily remember what concurrency does.
Source: here
Examples of Heavy CPU Computations
Not all problems require the use of web workers, but heavy computations and long-running tasks are some of the conditions that necessitate the use of web workers, including the following:
- Data encoding and decoding of a large string.
- Mathematical computations, such as finding all of the prime numbers in a large number.
- Background or data-intensive input and output operations.
Possible Problems That May Occur if Not Well Managed
- Bad user experience as a result of blocking operations.
- The web application becomes very slow and often freezes.
A long-running function will freeze the UI. Therefore, other sections of the page are forced to wait for its completion. Consequentially, it renders the page, buttons, and other features unusable.
Introducing Web Workers
Let's dive into some of the fundamental aspects of web workers. We would take the pieces together in the latter section and spawn workers for our simple demo projects. If you are already familiar with the basic concepts, skip this section.
How to Create a Web Worker Object
Because the web worker is independent of the main thread/script, we create a separate file for its code snippets. Below, its constructor loads the script located at "worker.js" and executes it in the background. If the script is not found, the worker will fail silently; otherwise, it will download the file and execute its code in the background.
Creating a worker object
//main.js
let worker = new Worker("worker.js");
Next, we want our workers to communicate with our JavaScript file to facilitate data exchange between the two independent threads.
Find below the types of data that can flow between them.
Sending Messages Between Threads
We can send data back and forth between the main application and our worker script. Calling the postMessage()
function ensures cross-origin communication between the two hands. Thus, we don't need to have the same host, protocol, or port to send a message to another party. We can send the following type of data as messages:
JSON, Strings, numbers, and arrays.
//main.js
worker.postMessage("hello world"); // Send this to the worker script.
//worker.js
postMessage("hi from worker"); // Send this back to the main script.
Receiving Posted Messages
We need to add event listeners to both sides to receive the messages above. We can either use onmessage
or addEventListener.
//worker.js
self.addEventListener('message', function(e) {
// Send the message back.
self.postMessage('You said: ' + e.data);
}, false);
Note that we use self for the worker to give a global reference to web workers because it is not a window object.
Event listener in the main script:
//main.js
let worker = new Worker('worker.js');
worker.addEventListener('message', function(e) {
// Log the workers message.
console.log(e.data);
}, false);
Terminating a Worker
We can kill a worker's task by using the terminate()
function or have it terminate itself by calling the close()
function on self. This helps reduce the memory consumption for other applications on the user's computer.
// Terminate a worker from your application.
worker.terminate();
// Have a worker terminate itself.
self.close();
Importing External Scripts
We can import libraries or files into a worker with the importScripts()
function. This function accepts zero or any number of filenames.
This example loads script1.js and script2.js into the worker: worker.js:
importScripts('script1.js','script2');
Example of Simple Web Workers in Action
This sample project will shed more light on making two scripts perform concurrency. One gets data and sends them to the other. The second processes it and sends it back. Here is the link to the source code for this project on my GitHub repo.
There are three files:
- An HTML file
- A worker script
- A JavaScript script
The script.js below sends two numbers to the worker's thread via postMessage.
first.onchange = function() {
myWorker.postMessage( [first.value, second.value] );
console.log('Message posted to worker');
}
second.onchange = function() {
myWorker.postMessage([first.value, second.value]);
console.log('Message posted to worker');
}
myWorker.onmessage = function(e) {
result.textContent = e.data;
console.log('Message received from worker');
}
} else {
console.log('Your browser doesn\'t support web workers.');
}
This is our worker script; it multiplies and sends the result back to the main thread.
//rest of the code here https://github.com/CaptainBKola/multithreadingjs/blob/main/multi/workers.js
const result = e.data[0] * e.data[1];
if (isNaN(result)) {
postMessage('Please write two numbers');
} else {
const workerResult = 'Result: ' + result;
console.log('Worker: Posting message back to main script');
postMessage(workerResult);
}
}
Web Workers and Building CPU-Intensive Applications in Action
In this section, we will do the following:
Create a web response to rendering a long-running or CPU intensive task on a single thread.
Use web workers to solve the problem by creating multithreading.
Side-Effect of Running CPU-intensive Tasks on a Single Thread
In this section, we will create a simple adverse effect of the single-threaded operation on a webpage. Here is the link to the source code for this project.
Create a JavaScript file and insert the code snippets below. We are looping over a large number; due to the large number, the page will be slow. Any attempt to click any other button on the UI will freeze the app, and we will get the response shown below. This is similar to fetching a larger chunk of data from the server synchronously.
//script.js
const startBtn = document.querySelector("#start");
startBtn.addEventListener("click", () => {
let finals = 0;
for (let i = 0; i < 45444444455; i++){
finals += i;
// rest of the code is on the repo [link] (https://github.com/CaptainBKola/multithreadingjs/tree/main/addition)
Solving the Problem Described Above with a Web Worker
Add the worker's file, create its constructor in the main thread, and use the script name as an argument. This adds more features to HTML so that it can be more dynamic. In the end, we have something with the UI below:
In the main script, we use the terminate()
method to kill our worker's operation.
//script.js
let countNum = document.querySelector('#upto').value;
myWorker.postMessage({'cmd': 'start', 'upto': countNum});
myWorker.addEventListener("message", addHandler, false);
}
function stop() {
if (myWorker) {
let msg = "WebWorker: Terminating " + new Date();
console.log(msg);
document.querySelector('#status').innerHTML= msg;
myWorker.terminate();
myWorker = null;
}
}
function addHandler(event) {
if (typeof(event.data)=='number'){
document.querySelector('#result').innerHTML = event.data
}
else {
document.querySelector('#status').innerHTML= event.data }
}
In the worker script, we use self to refer to workers. We use theself.close
method to end the workers’ connection. It is highly important that we close the connection when the workers’ operation is complete.
//worker.js
//self.onmessage = (event) => ('message', function(e){
self.addEventListener('message', (event) => {
let json_data = event.data;
let shouldRun = true;
console.log(json_data.cmd)
switch (json_data.cmd) {
case 'stop':
postMessage('Worker stopped the calculation ' + json_data.msg );
shouldRun = false;
self.close(); // Terminates the Worker.
break;
case 'start':
postMessage("Worker start and going to: " + json_data.upto + " (" + new Date()+ ")<br/>");
//rest of the code [here] (https://github.com/CaptainBKola/multithreadingjs/tree/main/addition-worker)
Conclusion
There are many more wonderful things we can do with web workers, aside from what have shown here; the most important thing is the ability to spot a web worker-related problem. This article has given enough insights and examples for readers to pinpoint what kinds of problems we can use web workers for and what we are trying to achieve (concurrency and parallelism) while using them.