What are Web Workers

This is my first look into the Web Workers API and I am sharing some of what I’ve learned. Exploring this topic brought me across a ton of concepts. The task then became to talk about these topics by expounding on them just enough to deliver a conceptual understanding of Web Workers. The article uses browser page rendering performance to demonstrate the use of Web Workers. Hopefully you will find most of it useful and it gives you enough insights to investigate further.

A Demo

What does Javascript do on a web page? Primarily it manipulates the DOM, we all knew that. We also know client side JS code can perform operations that are not directly related to resizing elements or changing styles. The problem is some such operations can be computationally expensive Javascript functions which can cause a page to lag or become unresponsive. This is what we will fix with Web Workers.

Consider this fabricated demo (you can also try this on a touch screen):

move the mouse pointer across the screen

Moving the cross appears jittery and is not so smooth (or is janky). Why ..?

A short detour: 60 FPS

When you are staring at the screen, the monitor is producing the display at 60 frames per second, that is, every second, 60 individual frames are drawn to the screen.

When things change on a web page such as an element is resized, or a scroll event is triggered, the browser will have to render the page based on this new change so you as a user can respond to it. Imagine if the browser took its time to paint the page after an event. It will feel unresponsive, like the case with the demo, you drag the mouse pointer and it is not keeping up with you.

For changes on a page to appear instantaneous, the browser will have to redraw the page every 16ms (1/60fps = time for one frame). This is not a requirement with all events for example clicking a button which changes the background colour of a page doesn’t require the browser to update the page 60 times in 1 sec since background colour change can be captured in a single frame or over a couple of frames, say 16ms * 6 frames = 96ms, which would still look pretty instantaneous. However, if there are too many user triggered events like for example when playing a game or in our case moving a cross that is tracking the mouse across the screen, the browser will have to redraw the page every 16ms to capture the change every 16ms. This provides a smooth enough rendering for any stuttering to be imperceptible to the human eye so that the user can continue to respond to the output.

Back to the Demo

document.addEventListener('mousemove', function() {
let cross = document.getElementsByClassName('x')[0];
cross.style.left = String(event.clientX) + 'px';
cross.style.top = String(event.clientY) + 'px';
pause(100); // I take 100ms to complete execution
.
.
});

The cross is a div element whose style property is changed to reflect the position of the mouse as it moves across the screen. Whenever the browser detects a change in content or style of an element it updates the page. As we have already discussed, a cross following the mouse pointer requires the browser to update the page every 16ms for smooth rendering. Here with mousemove events and subsequent DOM updates, we are adding 100ms to the 16ms time budget with a long running pause function. This means it takes say around 110ms to generate a frame (= update the page once). Continuous trigger of mousemove event over time, i.e. moving the cross across the screen, would then give us a frame rate of 9fps which will look very laggy .

Frame rate captured for demo (without workers)

The pause function is introduced in the event handler as a placeholder to demonstrate a computationally heavy script that is not concerned with changing anything on the user interface. In a production web application this long running code can be an image processing script or a script that processes large sets of data on the back of an XHR response or anything else that costs significant amount of computation time.

Now if you increase the delay high enough it will result in the cross movement becoming even more sluggish eventually turning the page and everything in it unresponsive (don’t have to try this). The browser sees this execution time (or delay) characteristic of an unresponsive script and returns an alert giving you the option to continue waiting or exit the page (page exit terminates the process, usually end up loosing tabs across the same domain).

Unresponsive Page Alert

So, a long running script can affect the performance and responsiveness of a web page. We will eventually see how Web Workers can help us solve this problem.

Let’s start by reviewing the tasks performed by the web browser here. Starting with the mousemove event, Javascript via the browser API makes changes to the page elements style property, Javascript also runs some time consuming code unrelated to DOM or CSSOM manipulation. The change of element style triggers a reflow (recalculation of element positions on the page) which is followed by repainting (filling pixels) of the viewport resulting in the updated position of the cross on the screen, i.e. the cross has moved. This sequence of steps happens every time a mousemove event is fired. A look into how the browser manages these tasks should tell us how a long running script can become render blocking.

The Browser Rendering Pipeline

When you fire up a browser, it spins up several processes (and threads) handling such responsibilities as network requests, browser UI interactions, data persistence/storage and page rendering. How these tasks are split into different processes (and threads) is implementation specific, however all browsers run the renderer process. This process is responsible for displaying content on the screen.

The renderer process spawns the main thread (among others). The main thread is responsible for parsing HTML, building the DOM, executing Javascript, and calculating layout of elements on the page all before the browser paints pixels on the screen. That is several things the main thread is doing before the final page is rendered. It is important to note that each step is dependant on the result from the previous operation. For example, if Javascript adds a node to the DOM, then all succeeding steps, i.e. generate render tree, calculate layout and request to paint is followed sequentially to render a page.

You can now see how JS run time affects how fast the page is rendered since it is a link in a chain of events that ends with an updated page.

Page rendering process (a generic model)

The demo JS code does two things, make changes to the DOM and run some unrelated code (pause function). JS code is executed whenever the main thread yields control to the Javascript engine. So when does that happen?

  1. One, during the initial page load. When the HTML parser comes across a script tag, JS code is executed. If this code is time consuming, regardless of whether it manipulates the DOM or not, page rendering will be delayed. Specifically speaking, the load time is affected (this article doesn’t cover an example).
  2. Two, during an input event such as a click, scroll or a mouse move (the demo example). The event handler executes associated JS code. The responsiveness of the page to the input event is affected because of time consuming code that runs every time the page tries to update after a change in the div elements style property.

Removing unnecessary Javascript from the rendering path means we get faster page updates and better response time to input events. Web Workers can help us with that.

Web Workers

Web Workers allows you to create additional threads to run scripts freeing up the main thread to work with just the user interface thus avoid processing any intense JS code that could otherwise block the user interface. Web Workers have their own execution context (called the Worker global scope) which is a non window context separate from global/window context, and its own event loop. Web worker objects also have access to couple of Web API’s contributing to its versatility for running different type of tasks in the background thread. Web Workers do not have access to the DOM API.

Creating a standard worker object is pretty straightforward. It is done by passing the constructor the URL for the script file that’s to be run in the background thread. These kind of workers are also called Dedicated Workers since they are linked to the parent thread and they exist for the duration of its creator.

// Window Context, main script
const worker = new Worker('worker.js');

Now we have the script (worker.js) loaded by the worker object, next there should be some means for the main thread to talk with the background worker thread and vice-a-versa.

This message passing between the main thread and worker thread is done through the postMessage() method.

// Window Context, main script 
worker.postMessage('hey do something for me!, thanks');
Message exchange between main script and worker scripts

Using addEventListener you can setup an event handler for the message event. This is how the main thread and the worker thread knows that they have received a message. The message itself is accessible through the event.data property.

// Worker Global Scope, worker script
// 'self' simply returns the current context (WorkerGlobalScope)
self.addEventListener('message', function() {
if (event.data === 'hey do something for me!, thanks') {
// doing stuff
if (taskComplete()) {
self.postMessage('all done!');
}
}
}

Once the task has been completed, the worker thread can be removed with worker.terminate().

// Window Context, main script
worker.addEventListener('message', function() {
if (event.data === 'all done!') {
worker.terminate();
}
}

Now let’s have a look at our demo implemented with web workers.

move the cross across the screen

Smooth. The long running JS code is executed on a separate thread from the main thread freeing up the latter to deal with tasks related to working with the DOM and updating the position of the cross on the screen.

One thing we haven’t discussed yet is the ‘Computations Complete’ counter. This counter keeps track of the number of times the long running code (the fabricated pause function) is called. In our demo implemented with workers, you can see the counter continues to increment even after the mousemove event stops. This shows that the pause function is run completely independent off the main thread allowing the browser to update the screen with the new position of the cross without the delay inherent with a long running script. Whereas the demo without workers has its counter start, increment, and stop with every mousemove followed by the obvious performance hit.

Performance Analysis

Chrome DevTools runtime analyser (Cmd + Option + J, Performance Tab) will help us summarise everything we’ve discussed so far.

Running the demo without a worker results in poor rendering of cross position on the screen. We know this is because of pause function taking up most of the time budget available to render a frame at 60fps.

Performance Analysis — Janky

Under the main thread the mousemove event takes significant amount of time, shown along the x-axis, to complete execution of its event handler (101.75ms). As a result the time it takes to render one frame is consistently over 110ms which is well above the 16ms time interval required for a 60fps output.

On the other hand, running the demo with a worker gives us jank free rendering attributed to the worker thread taking the task of executing time consuming Javascript code.

Performance Analysis — Jank Free

There is now the main thread and the worker thread shown in the performance panel. Frame rate is at a consistent 60fps with each frame taking around 16ms to render. The mousemove event handler execution, style calculation, layout calculation and painting pixels on the screen all happens within a small slice of 16ms (shown in tiny red circle). This time slice was eclipsed by JS code running in the event handler in the previous case.

The entire task of running the pause function is offloaded to the worker thread. You can see it takes a couple of frames (101.63ms) to complete however it does not affect page rendering in any way.

Wrap Up

Web workers are addressing concurrency with the single threaded nature of the browser page rendering process. The browser creates additional threads to process Javascript functions not related to UI interactions which allows the main thread to continue with the page rendering process.

You can read more on web workers in the provided links below. That’s all for now, cheers.

References and Further Reading: