Optimizing JavaScript Application Performance with Web Workers

April 29, 2019
Written by
Chuks Opia
Contributor
Opinions expressed by Twilio contributors are their own

GVM88uo3naEUNcFfBT0n7yNmsn9icXeWpa1UJvfkucDOf0Pp5ed84v0WhUJmGRexBP4Bk7pykZCKQX8irl04GPrt33XfwtPjW6G46nHYa7or5BqMwS76gD8LiX3m_1gt8VP2nlcQ

JavaScript has come a long way from being just a simple scripting language to being the de facto standard programming language for the Web. It's also being widely used to build server-side applications, mobile applications, desktop applications, and even databases.

Even though JavaScript is a great language for building complex and engaging software on the Web, it's possible performance inefficiencies can be introduced into these applications due to the nature of the JavaScript language.

In this post you will learn how to fix performance issues caused by long-running scripts in web applications by using web workers. A web worker is a JavaScript script that runs in the background, independently of user interface scripts executing from the same web page.

Prerequisites

To follow along, you will need a development server. If you don't already have one installed you can install the Web Server for Chrome extension for Google Chrome.

This post also assumes a basic knowledge of HTML, CSS, and JavaScript.

The case study project code for this post is available in a repository on GitHub.

The JavaScript Main Thread

JavaScript is single threaded, which means only one line of code can be executed at any given time. Tasks such as UI updates, user interactions, image transformation, and others that need to be performed are added to a task queue and executed one at a time by the browser's JavaScript engine.

This single-threaded pattern gives rise to a performance issue known as blocking. Blocking occurs when a particular task on the main execution thread takes a very long time to complete, thereby blocking every other task from being run. This problem manifests itself in web applications as a slow or sometimes frozen application, which is a huge turn-off for users.

Knowing that blocking poses a huge performance concern, JavaScript offers an API for running script operations in a background thread separate from the main execution. It is called the Web Workers API.

Web Workers

According to the Mozilla Developers Network (MDN) documentation: “Web Workers makes it possible to run a script operation in a background thread separate from the main execution thread of a web application. The advantage of this is that laborious processing can be performed in a separate thread, allowing the main (usually the UI) thread to run without being blocked/slowed down.”

Web Workers allow you spawn new threads and delegate work to these threads for efficient performance. This way, long running tasks which would normally block other tasks are passed off to a worker and the main thread can run without being blocked.

Spawning A Web Worker

A Web Worker is simply a JavaScript file. To spawn one, create a JavaScript file that contains all the code you want to run in the worker thread and pass the path to the file to the Worker constructor:

const worker = new Worker('worker.js');

The code snippet above creates and assigns a Worker to the worker variable. With this, the worker is ready to send and receive data from the application’s main thread.

Communicating With A Web Worker

Web Workers and the main thread send data to each other via a system of messages. This message can be any value such as a string, array, object, or even a boolean.

The Web Worker API provides a postMessge() method for sending messages to and from a worker and an onmessage event handler for receiving and responding to messages.

To send a message to or from a worker, call the postMessage() method on the worker object:

// send data from a JavaScript file to a worker
worker.postMessage(data);

// send data from a worker to a JavaScript file
self.postMessage(data);

To receive data from a worker or the main thread, create an event listener for the message event and access it via the data key:

// receive data from a JavaScript file
self.onmessage = (event) => {
  console.log(event.data);
}

// receive data from a worker
worker.onmessage = (event) => {
  console.log(event.data);
}

Note that data passed between a worker and the main thread is copied and not shared.

Terminating A Web Worker

Creating a Web Worker spawns real threads on the user’s computer which consumes system resources. It’s therefore good practice to terminate a worker when it has served its purpose. A worker can be terminated by calling the terminate() method on the worker. This immediately terminates the worker regardless of whether or not it’s performing a task. A worker can also be terminated from within its scope. To do this, call the close() method from within the worker:

// close a worker from the main script
worker.terminate();

// close a worker from itself
self.close();

Limitations Of Web Workers

The Web Workers API is a very powerful tool, but it has a few limitations:

  • A worker can’t directly manipulate the DOM and has limited access to methods and properties of the window object.
  • A worker can not be run directly from the filesystem. It can only be run via a server.

Setting Up The Demo Application

A demo application will be created to demonstrate how long running scripts affect performance in web applications. Ensure you have the Web Server for Chrome extension installed in Chrome before continuing.

Create a new folder, web_workers, on your computer and create an index.html file in the web_workers folder. Add the following code to the file:

<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <link rel="stylesheet" href="https://use.fontawesome.com/releases/v5.7.2/css/all.css"
integrity="sha384-fnmOCqbTlWIlj8LyTjo7mOUStjsKC4pOpQbqyi7RrhN7udi9RwhKkMHpvLbHG9Sr" crossorigin="anonymous">
  <link rel="stylesheet" href="styles.css">
  <script src="index.js"></script>
  <title>Flight</title>
</head>

<body>
  <i class="fas fa-space-shuttle fa-4x shuttle red" id="shuttle1"></i>
  <i class="fas fa-space-shuttle fa-4x shuttle blue" id="shuttle2"></i>
  <i class="fas fa-space-shuttle fa-4x shuttle green" id="shuttle3"></i>

  <button id="start" onclick="move()">Start</button>
  <button id="calculate" onclick="calculate()">Run Calculation</button>
</body>

</html>

The code above contains a basic HTML document with three <i> tags, each holding a space shuttle icon, and also two buttons. On clicking the first button, the space shuttle icons should move from left to right. Clicking the second button should run a CPU-heavy calculation.

Create a styles.css file and add the complementing styles for the markup above:

.shuttle {
  display: block;
  margin: 3rem 0;
  position: relative;
}

.red {
  color: red;
}

.blue {
  color: blue;
}

.green {
  color: green;
}

button {
  background-color: #e83c6c;
  border: none;
  border-radius: 0.3rem;
  color: white;
  padding: 0.7rem 1.5rem;
  font-weight: bold;
  cursor: pointer;
}

To see the demo application, open Web Server for Chrome, either by clicking the Apps shortcut in your Chrome bookmarks bar or by navigating to chrome://apps.

Click the Choose Folder button and select the web_workers folder wherever it is located on your computer. Click the toggle button to start the server and visit the web server URL shown in the Web Server for Chrome interface. You should see something similar to the image below:

example screenshot for app

To make the buttons functional, create an index.js file in the web_workers folder and add the following code to it:

const fibonacci = (num) => {
  if (num <= 1) return 1;

  return fibonacci(num - 1) + fibonacci(num - 2);
}

const calculate = () => {
  const num = 40;

  console.log(fibonacci(num));
  return fibonacci(num);
}

const move = () => {
  for (let i = 0; i < 3; i++) {
    const shuttleID = `shuttle${i + 1}`;
    let shuttle = document.getElementById(shuttleID);

    let position = 0;
    setInterval(() => {
      if (position > window.innerWidth / 1.2) {
        position = 0;
      } else {
        position++;
        shuttle.style.left = position + "px";
      }
    }, 5);
  }
};

The code above contains three functions; a move function that moves the shuttle images on the page forward by 1px every 5 milliseconds, a calculate function that returns the 40th number in the Fibonacci sequence, and a fibonacci function which holds the logic for calculating the index value of the provided number in the Fibonacci sequence using recursion. Calculating the 40th number in the Fibonacci sequence is resource intensive and will take a few seconds to run.

Refresh the demo application in the browser and click the Start button to move the shuttles. At any point in time, click the Run calculation button to run the fibonacci calculation. You’ll observe the shuttle animation freezes for a few seconds. This is a visual representation of how long-running scripts affect performance in web applications.

To identify the cause of the freezing animation, reload the browser tab, open the Developer tools (F12 or Ctrl + Shift + I), and switch to the Performance tab. Click the Record button (Ctrl + E) in the Performance tab to start JavaScript profiling, then click the Start button in the application, followed by the Run calculation button.

After the animation in the application freezes, click the Stop button in the Developer tools Performance tab to end the profiling. You should get a chart similar to the one in the image below:

Performance metrics tab

The highlighted section in the image above shows the activity on the main thread represented by flame charts. It shows a click event with a red triangle in the top right corner. The click event causes a function call on line 21 in the index.js file, which in turn calls the fibonacci function a couple of times.

The red triangle on an event is a warning that indicates a performance issue related to that event. This shows that the fibonacci function is directly responsible for the frozen animation on the page.

Improving Performance with Web Workers

To ensure the animated shuttles in the demo application are not affected by the fibonacci calculation, the recursive logic for the fibonacci calculation needs to be moved off the main thread so it is not blocked.

Create a file, worker.js in the web_workers folder and move the fibonacci function there from the index.js file:

const fibonacci = (num) => {
  if (num <= 1) return 1;

  return fibonacci(num - 1) + fibonacci(num - 2);
};

Now that the calculation logic has been moved to a worker, it needs to be called whenever the calculate button is clicked. In the index.js file, create a new worker instance and link it to the worker.js file by replacing the fibonacci function with the following statement:

let worker = new Worker("./worker.js");

Update the calculate function in the index.js file to send the number we want to calculate its index value in the Fibonacci sequence to the worker:

const calculate = () => {
  const num = 40;
  worker.postMessage(num);
};

Whenever the calculate function is called, the number 40 is sent to the worker to calculate the 40th number in the Fibonacci sequence. To access this number in the worker, add an onmessage event listener the top of the worker.js file with the following code.

self.onmessage = (event) => {
  const num = event.data;
  const result = fibonacci(num);

  self.postMessage(result);
  self.close();
};

The onmessage event handler gets the number from the data key in the event object and passes it to the fibonacci method to get the result of the calculation. Then it sends the result back to the main script and terminates the worker.

The fibonacci calculation is now being run on a different thread and the result is sent back to the main thread. To receive the result in the main script, add an onmessage event handler at the top of the index.js file:

worker.onmessage = event => {
  const num = event.data;
  console.log(num);
};

Reload the application in your browser, start the animation, and click the Run calculation button. You’ll observe that the result of the Fibonacci sequence calculation is still being logged to the browser console but this does not affect the movement of the shuttle images on the page.

To identify the performance impact of the web worker, refresh the browser tab with the Developer tools panel open and the Performance tab selected.

Repeat the steps above to start performance profiling, run the animation, and perform the Fibonacci calculation. You should see results similar to those shown below:

Performance after optimization

From the image above, the first obvious observation is the presence of a worker thread  alongside the main thread. The worker thread shows a function call in the worker.js file with an onmessage event, which in turn calls the fibonacci function multiple times. This shows that the fibonacci calculation no longer happens on the main thread, hence the improved performance in the shuttles animation.

Summary

In this post you learned how long running scripts affect web performance and how to fix these performance issues using the Web Workers API. You also saw how to use the Google Chrome developer tools to profile your JavaScript application's performance so you can identify bottlenecks that might benefit from being moved to web workers.

Additional Resources

MDN’s Web Workers API documentation is a great resource that contains everything there is to know about Web Workers.

W3Schools’ Web Workers documentation is another great resource with examples on how to use Web Workers.

You can find the complete code for this post on GitHub.

Chuks Opia is a Software Engineer at Andela. He is also a technical writer and enjoys helping out coding newbies find their way. If you have any questions please feel free to reach out on Twitter: @developia_ or GitHub: @9jaswag.