How To Handle CPU-Bound Tasks with Web Workers
JavaScript is commonly referred to as a single-threaded language because your web application code executes in a sequence, one after the other, in a single thread. If you are accessing a web app on a device with multiple cores, JavaScript only uses one core. When a task is executing on the main thread, all subsequent tasks must wait for the task to complete. When the task takes a long time, it blocks the main thread, preventing the remaining tasks from executing. Most of the blocking tasks tend to be CPU-intensive tasks, also known as CPU-bound tasks, with examples like processing graphics, mathematical calculations, and video or image compression.
Blocking and Non-Blocking Tasks
CPU-Bound Tasks
CPU-bound tasks take hold of the CPU until the completion of the task, blocking the main thread in the process. Even if you wrap them in a promise, they will still block the main thread. Further, users can notice when the main thread is blocked as the web app user interface (UI) may freeze, and anything using JavaScript may not work.
I/O-Bound Tasks
In addition to CPU-bound tasks, you will also have I/O-bound tasks, which are non-blocking. These I/O-bound tasks spend most of the time issuing requests to the operating system (OS) and waiting for a response. An example is a network request that the Fetch API makes to a server. When you use the Fetch API to fetch a resource from a server, the operating system takes over the task, and the Fetch API waits for the OS response. During this time, the Fetch API callbacks are offloaded to a queue where they wait for the OS response, freeing the main thread and allowing it to execute other subsequent tasks. Once the response is received, the callbacks associated with the Fetch API call execute on the main thread. Because the performance of I/O-bound tasks depends on how long the operating system takes to finish the task, most I/O-bound tasks, like Fetch, implement promises that define the functions that should run when the promise resolves; that is, when the operating system finishes the task and returns a response.
Solutions for Handling Blocking Tasks
To address the issue of blocking tasks, browsers introduced the Web Workers API to provide multithreading support in the browser. With Web Workers, you can offload a CPU-intensive task to another thread, which frees the main thread. The main thread executes JavaScript code on one device core, and the offloaded task executes on another core. The two threads can communicate and share data through message passing.
Overview of the Tutorial
In this tutorial, you will:
- Create a CPU-bound task that blocks the main thread in the browser and observe how it affects the web app.
- Attempt to make a CPU-bound task non-blocking using promises.
- Create a Web Worker to offload a CPU-bound task to another thread to prevent it from blocking the main thread.
Prerequisites
To follow this tutorial, you will need:
- A machine with two or more cores and a modern web browser installed.
- A local Node.js environment on your system. Follow the appropriate guide for your OS to install Node.js and create a local development environment.
- Knowledge of the event loop, callbacks, and promises. You can learn more by reading about these topics in JavaScript tutorials.
- Basic knowledge of HTML, CSS, and JavaScript. You can refer to foundational guides for learning these languages.
Step 1: Creating a CPU-Bound Task Without Web Workers
In this step, you’ll create a web app that includes a blocking CPU-bound task as well as non-blocking tasks. The application will have three buttons:
- The first button will start the blocking task, which is a
for
loop that iterates about five billion times. - The second button will increment a value displayed on the web page.
- The third button will change the web app’s background color.
The buttons for incrementing and changing the background are non-blocking tasks, while the blocking task will freeze the UI.
Setting Up the Project
Begin by creating a project directory and navigating into it:
mkdir workers_demo
cd workers_demo
Next, create the main HTML file for your application:
nano index.html
Adding HTML Structure
In the index.html
file, add the following code to create the basic structure of the web app:
Adding Styles
Create and open the main.css
file to style the elements:
nano main.css
Add the following CSS rules to define the app’s appearance:
body {
background: #fff;
font-size: 16px;
}
.wrapper {
max-width: 600px;
margin: 0 auto;
}
.total-count {
margin-bottom: 34px;
font-size: 32px;
text-align: center;
}
.buttons {
border: 1px solid green;
padding: 1rem;
margin-bottom: 16px;
}
.btn {
border: 0;
padding: 1rem;
}
.btn-blocking {
background-color: #f44336;
color: #fff;
}
#changebtn {
background-color: #4caf50;
color: #fff;
}
Adding JavaScript Functionality
Create and open the main.js
file to add interactivity:
nano main.js
Add the following JavaScript to implement the behavior of the buttons:
const blockingBtn = document.getElementById("blockbtn");
const incrementBtn = document.getElementById("incrementbtn");
const changeColorBtn = document.getElementById("changebtn");
const output = document.querySelector(".output");
const totalCountEl = document.querySelector(".total-count");
totalCountEl.textContent = 0;
incrementBtn.addEventListener("click", function incrementValue() {
let counter = totalCountEl.textContent;
counter++;
totalCountEl.textContent = counter;
});
changeColorBtn.addEventListener("click", function changeBackgroundColor() {
colors = ["#009688", "#ffc107", "#dadada"];
const randomIndex = Math.floor(Math.random() * colors.length);
const randomColor = colors[randomIndex];
document.body.style.background = randomColor;
});
blockingBtn.addEventListener("click", function blockMainThread() {
let counter = 0;
for (let i = 0; i < 5_000_000_000; i++) {
counter++;
}
output.textContent = `Result: ${counter}`;
});
Testing the Application
Start a simple web server using the npx serve
command and open index.html
in your browser. Interact with the buttons and observe how the blocking task freezes the UI while the non-blocking tasks function as expected.
Step 2: Offloading a CPU-Bound Task Using Promises
While promises are often used to manage asynchronous tasks in JavaScript, they do not inherently make CPU-bound tasks non-blocking. CPU-bound tasks still fully utilize the main thread until they are completed, even when wrapped in promises. In this step, you will demonstrate this limitation by wrapping the blocking task in a promise.
Creating a Promise-Based Function
Open the main.js
file and define a new function, calculateCount
, that wraps the CPU-intensive task in a promise:
function calculateCount() {
return new Promise((resolve, reject) => {
let counter = 0;
for (let i = 0; i < 5_000_000_000; i++) {
counter++;
}
resolve(counter);
});
}
This function initializes a new promise. Inside the promise, the CPU-intensive loop iterates five billion times to perform the computation. Once the loop completes, the result is passed to the resolve
function, indicating that the task is complete.
Using the Promise in the Event Listener
Modify the blockMainThread
event listener to use the calculateCount
function with the async/await
syntax:
blockingBtn.addEventListener("click", async function blockMainThread() {
const counter = await calculateCount();
output.textContent = `Result: ${counter}`;
});
By making the blockMainThread
function asynchronous, the await
keyword pauses its execution until the promise returned by calculateCount
resolves. Once resolved, the result is assigned to the counter
variable and displayed in the output
element.
Testing the Application
Save your changes and refresh the application in your browser. Click the “Blocking Task” button and observe the behavior of the UI:
- The blocking task begins and the UI freezes until the task is complete.
- After the task finishes, the result is displayed in the output area.
Despite using promises, the CPU-bound task still blocks the main thread, as the computation is performed entirely on the main thread. This demonstrates that promises do not make CPU-bound tasks non-blocking.
Conclusion
Promises are effective for managing asynchronous tasks, such as I/O-bound operations, but they cannot offload CPU-intensive computations to a separate thread. To solve this problem, you can use Web Workers, which will be covered in the next step.
Step 3: Offloading a CPU-Bound Task Using Web Workers
To handle CPU-bound tasks without blocking the main thread, you can use Web Workers. Web Workers run JavaScript code in a separate thread, allowing the main thread to remain responsive. In this step, you will move the CPU-intensive computation into a Web Worker.
Creating a Worker File
Create a new file named worker.js
and add the CPU-intensive task. This task will be offloaded to a separate thread:
let counter = 0;
for (let i = 0; i < 5_000_000_000; i++) {
counter++;
}
postMessage(counter);
In this code, the for
loop iterates five billion times to perform the CPU-intensive computation. The result is then sent back to the main thread using the postMessage
method.
Modifying the Main Script
Open the main.js
file and modify the blockMainThread
event listener to use the Web Worker:
blockingBtn.addEventListener("click", function blockMainThread() {
const worker = new Worker("worker.js");
worker.onmessage = (msg) => {
output.textContent = `Result: ${msg.data}`;
};
});
Here, a new Web Worker is created using the Worker
constructor with the path to the worker.js
file. The worker runs in a separate thread. The onmessage
event listener listens for messages from the worker, which are sent using the postMessage
method in worker.js
.
Testing the Application
Save your changes and refresh the application in your browser. Click the “Blocking Task” button and observe the following behavior:
- The blocking task executes in the Web Worker without freezing the UI.
- The “Increment” and “Change Background” buttons remain fully responsive while the CPU-intensive task runs.
- Once the computation completes, the result is displayed in the output area.
Explanation of the Improvements
By using a Web Worker, the CPU-intensive task is executed in a separate thread, freeing the main thread to handle other tasks. This significantly improves the user experience, as the UI remains interactive even when heavy computations are being performed.
Conclusion
In this step, you successfully offloaded a CPU-intensive task to a Web Worker, preventing it from blocking the main thread. Web Workers are an effective solution for handling computationally heavy tasks in JavaScript. For more advanced use cases, explore the Web Workers API documentation to learn about Shared Workers and Service Workers, which offer additional capabilities like offline functionality and inter-thread communication.
Conclusion
In this tutorial, you created a web application to explore how JavaScript handles CPU-bound and I/O-bound tasks. You learned that while promises can be effective for managing asynchronous operations like I/O tasks, they do not make CPU-bound tasks non-blocking. To address this limitation, you successfully offloaded a CPU-intensive task to a Web Worker, which allowed the task to execute in a separate thread, keeping the main thread responsive.
Key Takeaways
- JavaScript’s single-threaded nature: JavaScript executes code on a single thread, which can lead to blocking when performing CPU-intensive tasks.
- Promises: While promises help manage asynchronous operations, they cannot offload computational tasks from the main thread.
- Web Workers: Web Workers allow you to execute tasks in a separate thread, preventing the main thread from being blocked and improving the responsiveness of your web application.
Next Steps
After learning how to use Web Workers for CPU-bound tasks, you can further enhance your knowledge by exploring additional features and use cases of Web Workers:
- Shared Workers: Enable multiple scripts running in different windows, tabs, or iframes to communicate with a single worker.
- Service Workers: Provide offline capabilities and enable background synchronization for web applications.
- Message Passing: Learn how to send complex data structures between threads using structured cloning.
By integrating Web Workers into your projects, you can handle intensive computations effectively, ensuring a smooth and responsive user experience.