There are many problems that JavaScript’s single-threaded nature causes developers. Chief among them is combining long-running tasks with the UI components simultaneously. This sort of issue can cause a huge problem in a web app when a CPU-intensive task blocks the rendering of UI components, making the webpage freeze.
To solve this, we can simulate multithreaded JavaScript behavior. Often, developers use the built-in setTimeout function and event-driven architectures to mimic concurrency. Even the setTimeout()
function isn't part of JavaScript's native 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.
If you'd like to follow along with the code examples at home, you can find the source code for this project on GitHub.
Blocking operations in JavaScript
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 to user input.
One of the biggest 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
The JavaScript virtual machine (VM) is fundamentally designed to spawn one 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. While JavaScript still isn't multithreaded, the Web Workers API introduced in HTML5 allows us to offload heavy tasks to separate threads, enabling real parallelism in supported environments.
As an example, let's say we have an extensive background I/O operation to run from the server, and this will take a few more seconds. It's necessary that 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 run in a separate thread with their own isolated global context, allowing them to perform tasks without blocking the main thread.
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 switching between the tasks.
When the JavaScript virtual machine 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.
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 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 concurrency is 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 new worker object
//main.js
let worker = new Worker("worker.js");
Next, we want our worker object 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 web 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 it 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 JavaScript multithreading.
The 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)
Offloading canvas work with OffscreenCanvas
If your web application needs to render graphics to a <canvas>
element, you might have already noticed that these rendering tasks can become a performance bottleneck. Traditionally, all canvas rendering happened on the main thread, meaning any heavy drawing logic could block UI interactions.
OffscreenCanvas
is a modern API that lets you transfer control of a canvas element to a web worker. This means your drawing code can run in a background thread, completely separate from the main UI thread, leading to smoother performance and more responsive interfaces.
Using OffscreenCanvas
with workers is a powerful and simple way to keep your interface thread free from slow rendering. This is especially helpful for things like:
- Drawing large datasets
- Games and animations
- Image manipulation
Workers in Node.js
Most of this article has focused on web workers in the browser, but didn't I forget about server-side JavaScript? In its earlier history, Node.js was also single-threaded and leaned on the event loop to handle concurrency. Starting in Node.js 10.5.0, worker threads were introduced to give developers some new options.
Similar to web workers, Node.js worker threads allow you to run JavaScript in parallel on multiple threads (but on the backend). They're ideal for handling CPU-bound tasks that would otherwise block the Node.js event loop and slow down your server.
If you’re already using web workers in the browser, switching to worker threads in Node.js should feel pretty straightforward. It’s one more way the JavaScript ecosystem has evolved to meet users' rising expectations.
Using workers with WebAssembly
WebAssembly offers a solution for when you need even more performance out of a web app.
It's is a low-level format that can run in the browser. It’s designed to enable you to write performant code in fast languages and run it alongside your JavaScript in the browser. The result is near-native performance, even inside a web browser.
Combining WebAssembly with web workers allows you to move performance-heavy operations off the main thread and execute them at blazing speed without blocking your UI.
WebAssembly isn’t a requirement for modern web applications but it’s a game-changing option when you need it. And when paired with web workers, it becomes a powerful tool for building responsive, high-performance applications in the browser.
Taking advantage of multithreading in JavaScript
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 simulate multithreading in JavaScript.