Javascript Web Worker - learn and use now!

Photo by Josue Isai Ramos Figueroa

Before the topic discussion, let's understand the basic and often reading/hearing javascript phrase.

Javascript is single-threaded & synchronous.

Now we break this sentence.

Javascript is single-threaded

When we write a javascript code and give that to the browser for execution, our code is executed in a single thread by our javascript engine (eg. V8 in chrome). Meaning, we have only one call stack in our javascript engine, so only one task can be executed at a time.

Javascript is synchronous

Javascript code is executed line by line from top to bottom. Main thread has to wait until the function currently running in the call stack has to complete its execution before moving to the next line.

Browser's main thread is responsible for handling user events (eg. scroll, click), running javascript, rendering style and layout of our webpage. If a long running function is kept in the call stack, then the main thread has to wait for the function to complete its execution. During this waiting time by the main thread, our web page will become unresponsive (user won't experience scroll, click immediately).

Paul Lewis compares this with the 9AM rush hour:

There’s one lane from the city center to the outskirts, and quite literally everyone is on the road, even if they don’t need to be at the office by 9am.

This is how the web works! We have only one thread alloted by the browser to render our page and if we give every of our code to the main thread, there will be huge traffic leading our application to freeze and later become unresponsive.

Surma says in one of his article:

Most native platforms call the main thread the UI thread, as it should only be used for UI work...

The tasks other than manipulating the UI (such as calculating the largest prime number, computing the best route for maps) should go to a separate thread. In other words the tasks which take more than ~100ms should have its own thread in order to give the user a seamless experience.

The main thread has other responsibilities in addition to running a web app’s JavaScript. - Surma

Adding to this, RAIL a performance guide from Google says, time taken by the browser for rendering 1 frame of screen should be ~16ms to achieve 60FPS, which will give smooth animation. If we give high computational javascript code to run on the thread, it will delay rendering of frames.

Google search of https://www.tgpranesh.site

The above image shows the scrolling performance of Google search, and it is achieving ~16ms for rendering per frame.

So, to hit 60FPS performance we should keep our main thread available for rendering (painting) the frames. To make them available for rendering, we should keep our long running functions off the main thread. This functionality is provided by Web Workers, a Web API from our browser.

A worker is a javascript process that runs alongside the main script, on its own timeline.

A worker can be created by calling Worker class constructor by passing URI of the script to execute in worker thread.

1// index.js
2const worker = new Worker('./worker.js')

Data to be processed from the main script and the processed result from the worker are sent via event system. Workers cannot modify DOM, they don't have access to window, document or parent objects. But they can access navigator, location, timer functions (like setTimeout, setInterval), WebSockets, data storage mechanisms like IndexedDB.

1// index.js
2worker.postMessage({
3 name: 'Jack Sparrow',
4 occupation: 'Pirate Captain',
5})

worker.postMessage() let us send data from our main script to the worker file. As the worker is running as a separate thread, data sent from the main script is copied and not moved in order to prevent multiple threads using the same data. The Structured clone algorithm is used for this copy operation.

Worker script goes into seperate file and the moment postMessage is invoked, our worker's execution is started. An event with the name message is emitted inside the worker file.

1// worker.js
2self.addEventListener('message', event => {
3 const pirateData = event.data
4 processHeavyComputation(pirateData)
5})

Note: self is as same as this inside worker file

In the worker file, we should add an event listener for message event and from that event's callback we can get the data sent from the main script. Once we get our data, we can start our long running function.

Once our long running function is completed and the data is ready to send, again we can use the postMessage function inside our worker file to our main script.

1// worker.js
2function processHeavyComputation(pirateData) {
3 let result
4 // ...
5 // running some heavy time consuming process with the obtained data
6 // storing the result of computation in result variable
7 // ...
8 postMessage(result)
9}

Once the message is sent from our worker, an event named message is emitted in our main script file now. Inside the event's callback we can do the DOM manipulation with our obtained result.

1// index.js
2self.addEventListener('message', event => {
3 displayData(event.data)
4 worker.terminate()
5})

worker.terminate() will destroy the worker thread immediately, as it is listening for events it is good to close once we get the result back, to prevent memory leaks.

1// worker.js
2function processHeavyComputation(pirateData) {
3 let result
4 // ...
5 // running some heavy time consuming process with the obtained data
6 // 🚫 something went wrong while calculation
7 if (isError) {
8 self.close()
9 }
10 // storing the result of computation in result variable
11 // ...
12 postMessage(result)
13}

we can also stop the worker inside its own scope itself by using self.close()

worker.addEventListener('error', callback) is used to handle any error occurring inside the worker file.

1// index.js
2worker.addEventListener(
3 'error',
4 event => {
5 event.preventDefault()
6 console.log(event.message)
7 },
8 false
9)

Here event.preventDefault() is to prevent browser logging the error to the console.

Note: addEventListener('message', callback) is as same as worker.onmessage and addEventListener('error', callback) is as same as worker.onerror

Let's write a simple real world worker from our learning. JSON parsing is a costly operation when it comes to parsing large amounts of data. So, we will be writing a worker which handles parsing of JSON in a separate thread.

1// ==== index.js
2const worker = new Worker('worker-parser.js')
3
4worker.onmessage = function (event) {
5 var jsonObj = event.data
6 showData(jsonObj)
7}
8
9//send the 'huge' JSON string to parse
10worker.postMessage(jsonText)
11
12// ==== worker-parser.js
13self.onmessage = function (event) {
14 const jsonText = event.data
15 const jsonObj = JSON.parse(jsonText)
16
17 // sending back the parsed data
18 self.postMessage(jsonObj)
19}

Types of Web workers

  1. Dedicated web workers - These web workers will live until the main script which invoked lives. These cannot be accessed across the pages. Till now what we saw was dedicated web workers.
  2. Shared web workers - These web workers are sharable across pages. It is not recommended to use because Apple removed its support.

Conclusion

Workers have a high startup cost and a high instance of memory cost. But its impact won't be bad as much as running the same function in the main thread. Still if you feel web workers have complex API to implement, there are libraries like comlink which can make it simpler. So let's make our users happy by not freezing their browser.

05/18/2020
All posts
Built with ❤️ and  Gatsby