Leverage Multithreading in Javascript

Bidisha Mondal
Nerd For Tech
Published in
9 min readMay 29, 2021

--

Javascript as we know it is single-threaded. A single-threaded language is one with a single call stack and a single memory heap. This means that a single thread of execution handles the entire event loop. Despite being single-threaded, Javascript is asynchronous and has been able to solve complex modern web application concerns pretty well so far. However, as the complexity of computing systems increased manifold, executing complicated algorithms on a single execution context became more cumbersome and resulted in the slow loading of websites, translating to poor user experience.

Javascript has the Event Loop which has provided asynchronicity to our web applications but wasn’t viable enough to allow heavy computation on the frontend without blocking the single thread of execution leading to the browser crashing completely. One might argue however that computers nowadays are extremely fast and modern browsers have improvised by using either process-per-site-instances or different threads per tab and so with each tab using its own thread, webpages should have no problems. This is true for most use cases until your browser runs complex animations or renders an intricate visualization. These laborious processes end up blocking the main thread, which in turn, slows down page rendering leading to slow triggering of click/scroll events, frustratingly long loading states, and unresponsive webpages.

Web Workers come to the rescue in these dire situations. Web Workers were introduced by Javascript in 2009 and are still an experimental feature in some browsers ex. Mozilla Firefox. But using Web Workers can completely transform your webpage performance especially when heavy computation is involved on the client-side.

So, what are Web Workers?

A Web Worker is a script that is executed on a different thread other than the main thread. A Web Worker script can be triggered by a webpage running javascript. The invoked script is then run on a separate thread that spawns off from the main thread and hence takes the load off the main thread. The two threads can communicate via messages using the postMessage API.
Depending upon one’s use case, there are 2 types of Web Workers available as well, namely -

1. Dedicated Web Worker — A dedicated Worker is only accessible by the script that invoked it.
2. Shared Web Worker — A shared Worker is accessible by multiple scripts, and this is applicable even if they are being invoked by different windows, iframes, or other Web Workers.

Web Workers may spawn more worker threads if the need arises. But all of them must be hosted within the same origin as the parent page.

Where did I use them?

I came across Web Workers while solving a notoriously complex problem in an application that employed the use of Tensorflow.js. We were using a face-detection AI library built on top of Tensorflow.js to develop a proctoring application. We faced issues while integrating the library because the model was large and took a long time to load in the browser. Moreover, the algorithm computing the detections was a long-running and resource-intensive process and as a result, was crashing the browser or causing the system to hang completely. Web Workers was the answer to our predicament. We used Web Workers to spawn a separate execution context and ran the face detection algorithm in it. This caused the main thread to be unburdened by all the heavy computation and seamlessly handle other events and processes.

How do we implement Web Workers?

Ok, enough explanation about Web Workers, let’s get down and dirty with actually implementing one. Web Workers are pretty straightforward to implement. You will need at least 2 files, one will be the main script that will run on the main thread. The other will be the worker script which will run inside the Dedicated Web Worker. It is ideal to keep the worker script in a separate file as it will be run in a different and isolated execution context.

Let’s name the files mainFile.js and webWorker.js respectively.

Initialize a dedicated web worker in the mainFile.js which runs on the main thread.

A Shared Web Worker can be created in pretty much the same way, with the only difference being the constructor used during instantiation. We use the SharedWorker() constructor to spawn a Shared Web Worker.

Initializing a shared web worker.

If the specified file exists then it will be downloaded asynchronously and if not then the Worker will fail silently, and your web application will work even in cases of 404 errors.

Now that we have our Dedicated Web Worker in place, let's communicate with it by sending a message from the main thread. The Web Worker and main thread communicate using the postMessage() API.

Sending a message to the worker thread from the main thread using the browser’s postMessage() API.

Now our Web Worker has to listen for messages from the main thread to be able to respond to them. We will now set up a listener as follows -

Listen to the worker thread via onmessage() API to receive and respond to the message from the main thread.

The above example shows how the Web Worker listens for messages from the main thread via the onmessage() API.

There is however a difference in the way we communicate with a Shared Web Worker from the main thread.

Setting up a connection via port object and sending messages to the shared worker.

With a Shared Web Worker, we are expected to communicate via the port object – an explicit port is opened via which the scripts can communicate with the worker instance (this is done implicitly in the case of dedicated workers). We also need to explicitly call the start() function to set up the communication.

Setting up a listener on the Shared Worker thread is also slightly different. Consider the following -

Listener on shared worker thread to listen and respond to messages from the main thread.

As can be inferred from the example above, a Shared Web Worker script needs to listen to theconnect event, so that whenever any external context requests a connection, the worker script can respond appropriately.

The event object gives us access to the ports array, which helps us get the exact port that opened the connection request. Do note that instead of listening to message events, we can also maintain an array of saved ports and use this array to post messages to multiple contexts that are invoking and communicating with the shared worker.

We can also terminate a running Dedicated Web Worker instance, from the main thread by invoking the terminate() function -

Terminating a worker from the main thread.

The Web Worker instance is killed immediately along with all its ongoing operations if any.

Similarly, in the case of Shared Web Workers, we can close the connection by invoking the port.close() function, the worker thread will then be killed immediately without an opportunity to complete its operations or clean up after itself.

Terminating a shared web worker by closing the port connection.

With multiple connections in the case of a Shared Web Worker, the port connection will be closed only for that particular instance upon which the close() function is called. All other connections will remain open. You can check by running your application in different tabs and then try to close some random ports, other ports will remain active, and thus unaffected.

The above examples implement a simple Web Worker and demonstrate its communication with the main thread. Real-world scenarios requiring the usage of Web Workers are hardly going to be as effortless, so we must know about the caveats involved in the implementation of Web Workers.

What’s the catch?

Web Workers cannot access the DOM -

Since Web Workers run in a separate execution context, they do not have access to DOM elements. One will not be able to manipulate the DOM, access global variables from the main thread, or trigger its functions. One also cannot access the default methods and properties of the window object. However, you do have access to functions such as setTimeout() and setInterval(). You can use the XMLHttpRequest object to make Ajax requests to the server as well. You are also allowed usage of WebSockets and data storage mechanisms like IndexedDB. The context within a Worker thread can be accessed via DedicatedWorkerGlobalScope or SharedWorkerGlobalScope depending upon the kind of Worker being used. The former works with a Dedicated Web Worker and the latter with a Shared Web Worker respectively.

Content Security Policy -

Web Workers have their own execution context independent of the document that spawned them and hence they are not governed by the Content Security Policy of the parent thread/worker. The exception to this is if the worker script’s origin is a globally unique identifier (for example, if its URL has a scheme of data or blob). In this case, the Worker inherits the content security policy of the document or Worker that invoked it.

The amount of data passed to Web Workers can impact performance. Also, supported types are limited -

The amount of data that you pass a Web Worker has a direct impact on the performance of your web page. As already discussed, Web Workers have a separate execution context, which means it has a separate memory and call stack as well and cannot access variables from the main thread. So all the information passed onto the worker thread is cloned or duplicated using the Structured Clone Algorithm. This algorithm traverses the data (usually passed as an object) recursively. While this may not cause hiccoughs for small data sets, passing a large object with numerous deeply nested key-value pairs to a worker will significantly slow down the inter-thread communication and also consume all your computational resources, inadvertently doing more harm than good. There are also limitations on the type of data a Web Worker accepts. Things that cannot be cloned using a structured clone algorithm cannot be passed to a Web Worker ex. functions, DOM nodes, and some properties are lost on cloning as well. One way around cloning objects is to use transferrable objects like SharedArrayBuffer.

Ensure Atomic updates to transferrable objects like SharedArrayBuffer -

Multithreading cannot be truly achieved without the provision of shared memory. If one has to copy-paste their entire data structure every time a change is required, then the whole idea of running code on separate threads is pointless. There is a workaround for that too and it’s called SharedArrayBuffer. SharedArrayBuffer is an object used to represent a generic, fixed-length raw binary data buffer as explained by MDN. This implies that instead of sending data and cloning it from the main thread we can update the same shared memory on both threads. This allows both threads to have a reference to the shared memory, thus eliminating the need to clone data at all. Like so -

Creating a sharedArrayBuffer on the main thread.

When the worker receives this buffer, we can create another array using the same buffer similar to the above approach -

Creating an array from the buffer passed by the main thread.

Now we can reference the same shared memory instance on either thread, with the only concern remaining being to update those values seamlessly across threads.

While sharing memory across multiple threads, we might end up with a situation called a Race Condition. A race condition is likely to occur when multiple threads try to modify the same data simultaneously. This leads to stale and inconsistent data. This brings in the concept of the Atomics object.

The Atomics object provides functions that operate indivisibly (atomically) on shared memory array cells as well as functions that let agents wait for and dispatch primitive events. When used with discipline, the Atomics functions allow multi-agent programs that communicate through shared memory to execute in a well-understood order even on parallel CPUs.

In short, Atomics allows you to operate on shared data predictably. Atomics is not restricted to only read/write operations. Sometimes multi-threaded operations require you to listen and respond to changes. Atomics has provisions to do all of the above needfuls. Atomics provides access to a set of static methods that help handle and mitigate the dangers of multithreading like deadlocks, unpredictable order of reading and write operations, and data fragmentation.

Conclusions

Multithreading in JavaScript is slowly gaining momentum and popularity. The prospect of being able to offload work into multiple threads and share memory across them has immense potential. In my opinion, the idea of SharedArrayBuffers and multithreading is beneficial especially if it comes to NodeJS services. It's still in a rudimentary phase as far as cross-browser support is concerned. Support for Worker Threads is still experimental in most browsers, Google Chrome being one of the compatible ones. Javascript multithreading is promising and can transform traditional web app capabilities. We can bring complex animations, AI integration, and more complex computation capacity to client-side applications with web workers. As adoption gradually increases we might see more of these across the web until then it's never a loss to learn and try out new things.

Happy Coding!!

--

--