Skip to content

The Web Has Threads! Building Super-Charged Parallel Web Applications.

Posted on:August 14, 2023 at 12:02 PM

On average it’s takes a human person tenth of a second(100 milliseconds) to blink.

Which is to fully close and open their eyes.

The browser’s main thread has one-sixth of a human blink(100 milliseconds), approximately 16.67 milliseconds to paint a flawless frame.

The main thread is overworked and underpaid , can we help it?

Web workers have been around for years, a way for browsers to spawn a separate thread.

Allowing the main thread to shine at what it does best - painting the browser.

source code: git

Web Worker: introduction

Working with threads is not as hard as it sounds! and I am willing to put my head on a block and say in most high level languages.

To most beginners the word thread is ominous, at least it was for me.

Most high level languages provide beautiful abstractions,

unless you are working on a complex project, you will never encounter most thread associated problems.

Having created an internal data frame for a company based on web threads, I am happy to report,

I haven’t encountered a single one from that list, the browser is very well abstracted!

The only practical, which we will develop a simple solution for, is establishing a communication pattern.

Create a simple HTML , CSS and JavaScript project, I use the vscode live server plugin to serve the project.

live server vs code plugin


src\
   app.js
   thread.js
index.html

index.html starter:

<!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>Threads</title>
</head>
<body>

    <div style="width: 100%; display: grid; place-content: center; padding: .8em;">
        <button id="btn">fetch</button>
    </div>
    <div class="app">

        <div class="container">

        </div>


    </div>
   <script type="module" src=".\src\app.js"></script>
</body>
</html>

Let’s ignore the fetch button and container for now, they will become relevant later, when use the pokemon API, with workers.

In app.js let’s spawn a thread, in the web: workers is the official name for threads, web workers. Saying threads, is way easier and sounds way cooler, you can use any.

// app.js
document.addEventListener("DOMContentLoaded", () => {
  // new URL means read\load thread.js relative to us the app.js file
  // not our website URL, this case localhost
  const worker = new Worker(new URL("./thread.js", import.meta.url));
});

The new keyword is always associated with constructing, constructors,

we are constructing an object, on top of spawning a separate thread,

new Worker runs thread.js in the spawned thread, while also returning an object

Having properties describing the thread and functions for interacting with and between threads.

Add a console.log so we can see when the thread is being spawned, as it immediately run’s thread.js:

console.log("Thread running.......");

Our interest at the moment is communication/passing data:

meet postMessage and onmessage.

postMessage and onmessage

To exchange data\messages between the threads we use postMessage

From the main thread to the worker:

// we user the constructed worker object
// app.js

document.addEventListener("DOMContentLoaded", () => {
  const worker = new Worker(new URL("./thread.js", import.meta.url));
  worker.postMessage();
});

postMessage takes two parameters

data/message - a value to passed to the concerned thread, it can be any JavaScript value, copied using structured cloning.

transfer - an optional array of transferrable objects , to transfer ownership of an object from one thread to another,

we will not cover transferrable objects in this article.

Passing a string to the worker:

   document.addEventListener("DOMContentLoaded", ()=> {
       ...
       worker.postMessage("hello from main")
  })
   

To pass data from the worker we do the same, but instead of using a constructed object,

we use self:

console.log("Thread running.......");

self.postMessage("hello from worker thread");

Now we need to catch these broadcasts, which is where onmessage come’s in:

In main let’s listen for a worker message:

  document.addEventListener("DOMContentLoaded", ()=> {
       ...
   
       // listening for the message event
       // the passed message will be on e.data
       worker.onmessage = e => {
         console.log(e)
       }
   
      worker.postMessage("hello from main")
  })
 

In the worker :

console.log("Thread running.......");

self.onmessage = e => {
  console.log(e);
};

self.postMessage("hello from worker thread");

Simple right?, yeah this is where we meet our first and hopefully last pain point, when we pass a message to a thread,

there are few things we need to know, e.g what the message is?, what to do with it? was there an error? etc, this is the communication problem I pointed out earlier.

we need a way to encode this information, each thread needs to know what’s happening on either side.

Having worked with workers quite a bit,

I settled on simple event driven system, that is extensible, which you can make as complex as you need

The communication problem

The solution inspired by the event driven architecture, w/o all the complexities of adapters and so on.

Objects are GOATED in JavaScript, they are dynamic, can take any shape, a bonus O(1) - instant access. The idea is simple, objects are the only data we allow ourselves to pass.

This makes it possible to send any form of data, to any thread, the only condition being, we have a few reserved keys: event, isError, Error

meaning any thread should expect an object with these keywords by default:

This way all the threads will always be in sync with each other’s status, an example will do more justice than explanation.

The first thing we do is declare a global object,

const EventObject = { event: "", isError: false, Error: null };

When we post a message, we override or add properties to this object, as required for our use case,

worker.postMessage({ ...EventObject, ...{ event: "pokemonFetch" } });

Thus creating a new object from the given,

In every thread now we know, that the event property controls the flow, Which makes event handling even better,

That is literally a switch statement:

self.onmessage = e => {
  // extracting data from event, and event, isError and Error from data
  const {
    data: { event, isError, Error },
  } = e;
  if (!isError) {
    switch (
      event
      // relevant computation here
    ) {
    }
  } else {
    // handle error
    console.error(Error);
  }
};

All this will come together and be solidified in the pokemon app example.

Pokemon API example

This part is, to solidify the concepts we have learned so far, in a ‘real’ scenario.

We will fetch data from an API, using a thread, then passing the data to the main thread.

Main will only handle constructing the UI from the given data, this part will move a bit faster, as we have covered all the fundamentals.

Fetch

navigate to app.js

Remember the container and the fetch button we skimmed over in beginning, we are back to them, the button will trigger a fetch request to the PokeAPI

//update app.js
const EventObject = { event: "", isError: false, Error: null };
document.addEventListener("DOMContentLoaded", () => {
  /**
   * @type {HTMLButtonElement}
   */
  const btn = document.querySelector("#btn");
  container = document.querySelector(".container");
  btn.onclick = () =>
    worker.postMessage({ ...EventObject, ...{ event: "pokemonFetch" } });
  // will be hoisted
  const worker = new Worker(new URL("./thread.js", import.meta.url));
});

We are sending pokemonFetch event to the worker, where fetching will be handled, and the worker will respond with the pokemonFetch_Res event, let’s handle that below:

//update app.js
document.addEventListener("DOMContentLoaded", ()=> {
...

    worker.onmessage = e => {
       console.log(e)
        const {data: {event, isError, Error, res}} = e

        if(!isError){
            switch(event){
              case "pokemonFetch_Res":
                  console.log(res, "response")
                  AddToDom(res.results)

              default:
                  break;
            }
          }else{
              console.error(Error)
          }
    }

})

Everything we are doing in the switch we have discussed above, we only need the AddToDom function,

which will take the results, given there’s no error, and create dom elements from them:

// app.js
const EventObject = {event: "",isError: false, Error: null}

let container;
/**
 *
 * @param  {Array<{url: string, name: string, img: string}>} results
 */
function AddToDom(results){
    results.forEach(v => {
        const card = document.createElement("div")
        card.classList.add("card")
        const img = document.createElement("img")
        img.src = v.img
        card.appendChild(img)
        const label = document.createElement("label")
        label.innerText = v.name
        card.appendChild(label)
        if(container)
           container.appendChild(card)
    })
}


document.addEventListener("DOMContentLoaded", ()=> {
...
})

The code is creating cards, that looks like image below:

pokemon cards

Here is the css for the entire thing, you can place it in separate file, for me I placed it in the head element, inside a style tag in index.html:

<style>
  * {
    box-sizing: border-box;
  }

  body {
    margin: 0;
    padding: 0;
    width: 100vw;
    height: 100vh;
  }

  .app {
    width: 100%;
    height: auto;
    background: whitesmoke;
    /* display: flex;
            flex-direction: column;
            gap: .5em; */
    padding: 5em;
  }

  .container {
    display: grid;

    grid-template-columns: repeat(4, 1fr);
  }

  .card {
    display: flex;
    /* flex: 1; */
    align-items: center;
    justify-content: center;
    flex-direction: column;
    width: 200px;
    height: 200px;
    box-shadow: 0 3px 6px rgba(0, 0, 0, 0.16), 0 3px 6px rgba(0, 0, 0, 0.23);
  }

  .card img {
    width: 50%;
    height: 50%;
    object-fit: contain;
  }

  #btn {
    display: inline-block;
    outline: 0;
    cursor: pointer;
    padding: 5px 16px;
    font-size: 14px;
    font-weight: 500;
    line-height: 20px;
    vertical-align: middle;
    border: 1px solid;
    border-radius: 6px;
    color: #24292e;
    background-color: #fafbfc;
    border-color: #1b1f2326;
    box-shadow: rgba(27, 31, 35, 0.04) 0px 1px 0px 0px, rgba(
          255,
          255,
          255,
          0.25
        ) 0px 1px 0px 0px inset;
    transition: 0.2s cubic-bezier(0.3, 0, 0.5, 1);
    transition-property: color, background-color, border-color;
  }

  #btn:hover {
    background-color: #f3f4f6;
    border-color: #1b1f2326;
    transition-duration: 0.1s;
  }
</style>

Everything for the main thread is complete, let’s navigate to threads.js, and finish the entire cycle:

// thread.js
const EventObject = { event: "", isError: false, Error: null };

self.onmessage = e => {
  const {
    data: { event, isError, Error },
  } = e;
  if (!isError) {
    switch (event) {
      case "pokemonFetch":
        pokemonFetch()
          .then(async v => {
            // implemented below
            // checking if it's an error (pokemonFetch() returns type Err or Response)
            if (v instanceof Response) {
              const res = await v.json();
              // implemented below
              constructImagesUrl(res);
              self.postMessage({
                ...EventObject,
                ...{ event: "pokemonFetch_Res", res },
              });
            } else {
              self.postMessage({
                ...EventObject,
                ...{ isError: true, Error: v },
              });
            }
          })
          .catch(err => {
            self.postMessage({
              ...EventObject,
              ...{ isError: true, Error: v },
            });
          });
      default:
        break;
    }
  } else {
    console.error(Error);
  }
};

Threads.js is a little involved, the code is still similar somewhat to the main thread code, in terms of the switch statement,

when we receive the pokemonFetch event, we make a request to the PokeApi:

async function pokemonFetch() {
  const pokemons = await fetch("https://pokeapi.co/api/v2/pokemon/");

  if (pokemons) return pokemons;
  else return new Error("failed to fetch pokemons");
}

A normal fetch request, the Poki API only returns pokemon information, we need to construct the url for image’s ourselves, they are hosted on git,

Which is what the function below is doing:

/**
 *
 * @param {Object} res
 * @param {Array<{url: string, name: string}>} res.results
 *
 */
function constructImagesUrl(res) {
  res.results.forEach(v => {
    const temp = v.url.split("/");
    temp.pop();
    if (temp) {
      v.img = `https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/${temp.pop()}.png`;
    }
  });
}

And that completes our switch statement, and we finally made it, you can test the app by hitting the fetch button, the container should be populated with 20 pokemon’s, to top it all we have a non-blocking main thread only focused on the UI,

We have accomplished our goal, aid the main thread!

This article is a start of many involving web threads.

You can connect with me on twitter, I am new!

and if you like concise content, I will be posting fuller articles and projects soon for free, as they do not make good dev.to blogging content,

Articles on Machine Learning, Desktop development, Backend, Tools etc for the web with JavaScript, Golang and Python, if that’s your thing make sure to bookmark the site and follow me on twitter for article updates,