Web Workers in 8 Minutes

Now even though javascript is single threaded it handles the async operations well without blocking the main thread, thanks to the synergy between the call stack, web APIs, the callback queue and the event loop. But what if the code that is blocking the main thread is not an asynchronous operation? What if there’s a while loop that has an edge case which makes it run forever? Your call stack will always be occupied with this loop operation and if there are any items waiting in the callback queue, they’ll stay there indefinitely. If only we could offload these potentially thread-blocking tasks to a separate worker that does not run on the main thread. You can already see where this is going. Yes, I’m talking about web workers.

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 is that heavy processing can be performed in a separate thread, allowing the main thread to run without being blocked.

There’s a video version of this post available on Youtube.

On a side note, by separating heavy tasks into web workers, the way you organize your code also improves. This separation simplifies code maintenance and promotes a more modular structure in your codebase.

JavaScript provides two types of web workers: dedicated and shared.

  1. Dedicated workers are specific to a single instance of a web page. They can communicate only with the page that spawned them. These workers are ideal for scenarios where a particular task needs to be performed in the background for a specific web page.

  2. Shared Workers unlike dedicated workers, can be shared among multiple web pages originating from the same domain. They enable concurrent processing of tasks across multiple pages, promoting better resource utilization and intercommunication between different web pages.

Let’s consider an example of a dedicated web worker in JavaScript that performs a computationally intensive task: calculating prime numbers(in the worst possible way).

    <!DOCTYPE html>
    <html lang="en">
    <head>
        <meta charset="UTF-8">
        <meta http-equiv="X-UA-Compatible" content="IE=edge">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
        <title>Document</title>
    </head>
    <body>
        <button id="my-btn">Click me!</button>
        <script>
            const calculatePrimes = () => {
                const primes = [];
                for (let i = 2; i <= 10000; i++) {
                    let isPrime = true;
                    for (let j = 2; j < i; j++) {
                        if (i % j === 0) {
                            isPrime = false;
                            break;
                        }
                    }
                    if (isPrime) primes.push(i);
                }
                return primes;
            }
            console.log(calculatePrimes())
        </script>
    </body>
    </html>

In this example, we calculate prime numbers from 2 to 100000. Now if you run this in the browser, you’ll see that the button does not render on the page until the calculation is complete. This is what I mean, when I say “blocking the main thread”.

Now to handle this issue, let’s use a worker.

    //worker.js
    onmessage = event => {
        if (event.data == "start") {
            const primes = [];
            for (let i = 2; i <= 100000; i++) {
                let isPrime = true;
                    for (let j = 2; j < i; j++) {
                        if (i % j === 0) {
                            isPrime = false;
                            break;
                        }
                    }
                    if (isPrime) primes.push(i);
                }
            postMessage(primes);
        }
    }

    //script.js
    const worker = new Worker("./worker.js")
    const calculatePrimes = () => {
        worker.postMessage("start")
    }
    calculatePrimes()
    worker.onmessage = event => {
        console.log(event.data);
    }

Instead of calculating the prime numbers inside this file, we’ll create a new worker instance and let it do the calculation. A worker instance will be a simple JS file that’ll have the complex calculation. Inside the calculatePrimes function, we use worker.postMessage to notify the worker to start the calculation. At the end, inside the main script, we add an onmessage event listener on the worker instance to get the message(the prime numbers) back from the worker. In the worker file, we’ll again attach an onmessage event listener. It’ll wait for the start message from the main script and when it gets the message, it should start the calculation. So you’ll have to copy the calculation inside this event listener. In the end, instead of returning the array, we’ll send it back using the postMessage method that’s accessible globally.

Now if you run this inside the browser, there should be no blockages. The main thread is not blocked anymore which is why you get the button right away. The worker thread is calculating the prime numbers simultaneously and will send back the result to the main thread once it’s done.

Now the reason why this is called a dedicated worker thread is that this worker works only for the script that created it. Any other script inside this project won’t have access to this worker instance. So if you have several scripts and each script for some reason has a thread-blocking task, then you’ll need to create multiple workers. This is where shared workers come in.

Shared worker

The goal here is the same. To offload heavy computation to a worker thread. But this time, we can access the worker instance from different pages. The only catch here is that each page that will access the worker needs to have the same origin, which is a combination of the domain, the protocol, and the port.

Alright, let’s create a Shared worker. Inside our main script file, we’ll use the SharedWorker instance to create a shared worker. One big difference with a shared worker is that you have to communicate via a port object — an explicit port is opened that the scripts can use to communicate with the worker. This is done implicitly in the case of dedicated workers.

To start the port connection between this script and the worker, we can use the onmessage event listener. There’s another option to do this explicitly which is the start method, but onmessage not only helps you with your callbacks but also implicitly creates a connection, so it’s much more convenient.

So inside the main script file, you’ll have to change the Worker class to SharedWorker. Attach the port on the postMessage as well as the onmessage functions.

    const worker = new SharedWorker("worker.js")
    const calculatePrimes = () => {
        worker.port.postMessage("start")
    }
    calculatePrimes()
    worker.port.onmessage = event => {
        console.log("Message received from the worker.", event.data)
    }

Inside the worker file as well, we’ll have to make some changes. First, we use an onconnect handler to fire code when a connection to the port happens so basically when the onmessage event handler in the parent thread is set up. We use the ports attribute of this event object to grab the port and store it in a variable. The rest of it is the same. Just that the postMessage and onmessage events will run on the port, so just add it to both of your functions. Now if you run this in the browser, you’ll see that everything works as expected.

    onconnect = e => {
        const port = e.ports[0];
        port.onmessage = event => {
            if (event.data == "start") {
                const primes = [];
                for (let i = 2; i <= 100000; i++) {
                    let isPrime = true;
    
                    for (let j = 2; j < i; j++) {
                        if (i % j === 0) {
                            isPrime = false;
                            break;
                        }
                    }
    
                    if (isPrime) primes.push(i);
                }
    
                port.postMessage(primes);
            }
        }
    }

Now let’s create another script(index2) that will use the same shared worker for some other calculation. Just copy the whole script from the first file and add it to the second file. Instead of sending start, we’ll send “start2” for the worker to distinguish between scripts.

Inside the worker, you can add another if statement that’ll deal with the calculation for the second script. Now this implementation can be a lot cleaner but I just want to show you how you can set the worker to do different tasks based on inputs from different workers. Let’s just send back a message without doing anything for this worker. port.postMessage(“Heavy calculation”)

    onconnect = e => {
        const port = e.ports[0];
        port.onmessage = event => {
            if (event.data == "start") {
                const primes = [];
                for (let i = 2; i <= 100000; i++) {
                    let isPrime = true;
    
                    for (let j = 2; j < i; j++) {
                        if (i % j === 0) {
                            isPrime = false;
                            break;
                        }
                    }
    
                    if (isPrime) primes.push(i);
                }
    
                port.postMessage(primes);
            }else if(event.data == "start2") {
                port.postMessage("Heavy calculation");
            }
        }
    }

Now if you go inside the browser and open up index2.html inside localhost, you’ll find the console.log statement which is coming from the same worker. So we’re essentially sharing this worker from 2 different scripts for 2 different calculations.

Conclusion

And with that, we’ve covered the basics of Web workers in JavaScript. They are a powerful tool for improving web application performance by leveraging multithreading and background processing. You can do a lot with these workers like for instance if you want one tab of your app to communicate with another tab, a shared web worker could do that pretty easily. It might not be the most efficient way of doing so, but it certainly is possible. Apart from this, if you have any doubts or suggestions, you can put them in the comments or get in touch with me on any one of my socials. Cheers!

There’s a video version of this post available on Youtube.