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:

Adding HTML Structure

In the index.html file, add the following code to create the basic structure of the web app:

 


  
    
    
    

 

 

 

 

 

<!DOCTYPE html>
<html lang=”en”>
<head>
<meta charset=”UTF-8″ />
<meta name=”viewport” content=”width=device-width, initial-scale=1.0″ />
<title>Web Workers</title>
<link rel=”stylesheet” href=”main.css” />
</head>
<body>
<div class=”wrapper”>
<div class=”total-count”></div>
<div class=”buttons”>
<button class=”btn btn-blocking” id=”blockbtn”>Blocking Task</button>
<button class=”btn btn-nonblocking” id=”incrementbtn”>Increment</button>
<button class=”btn btn-nonblocking” id=”changebtn”>
Change Background
</button>
</div>
<div class=”output”></div>
</div>
<script src=”main.js”></script>
</body>
</html>

 

 

 

 

 


Adding Styles

Create and open the main.css file to style the elements:

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:

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.

Create a Free Account

Register now and get access to our Cloud Services.

Posts you might be interested in: