API DevelopmentWeb Development

Improving Web App Performance with Web Workers

banner-Web-App-Performance

Introduction

Web applications are, for the most part, single-threaded. All of the code, including the UI and any other custom logic, is executed sequentially. For 99% of use cases this is a good thing, as it makes the app much simpler to write and easier to debug. On the other hand, it means that if the client needs to do anything computationally expensive, the entire app will freeze until it completes. To get around this problem, it’s possible to have the application spawn a new thread to compute in the background by utilizing the Web Worker API.

Creating the Worker

One of the rules of web workers is that they need to be declared in a separate JavaScript file in order to isolate them completely from the main thread. This also means that there is very limited communication between the worker and the main thread. Essentially, they can only communicate by sending message and error events to each other.

To start, we can create a very basic worker file called example.worker.js that looks like this:

console.log(‘Hello from the worker!’);
self.postMessage(‘Done’);

Here, the worker simply writes some output to the browser console and then sends a message back to the main thread using self.postMessage. This will send a message event containing the string Done, which must be handled back on the main thread.

The next step is to execute this script from the main thread. The default method would be to instantiate the worker using the filename, like new Worker(‘example.worker.js’). However, with the addition of a bit of Webpack configuration, we will be able to import the worker just like we would any other module. First, we can add the following rule to our webpack.config.js to tell it how to load the worker file:

{
  test: /\.worker\.js$/,
  use: [‘worker-loader’, ‘babel-loader’],
  include: [path.join(__dirname, ‘src/workers’)],
}

Here, we are telling Webpack that for any file ending in .worker.js, it should load the file using worker-loader in addition to our typical babel-loader. The loaders are executed from right to left, so the worker files will first be transpiled by Babel, then the resulting file will be loaded as a worker. This will allow us to use advanced ES features in our worker as well as import NPM packages easily.

Using the Worker

Now that we have a basic web worker, let’s use it! A simple function that makes an API call might look something like this:

  // This could be setting loading indicators, clearing errors, etc.
  setRequesting(true);

  // Retrieve some data from our state to send with the request
  const { user } = this.state;

  return service(user).then(result =>
    // Get the response and store it
    setRequesting(false);
    setUserData(result);
  )
}

Now let’s say we want to execute the code in our worker before making the service call. First, we’ll need to import the worker file:

import Worker from '../workers/example.worker.js';

Next, we can create a function that instantiates our worker, wraps it in a Promise, and awaits a message from the worker:

  const worker = new Worker();
  const message = await new Promise((resolve, reject) => {
    worker.addEventListener('message', event => resolve(event.data), false);
    worker.addEventListener('error', reject, false);
  })
  return message;
}

Since the only way for our main thread to receive data from the worker is through the ‘message’ event, wrapping it in a Promise allows us to use async/await syntax instead of a callback function. We can now call this function from our service function before sending the request:

  setRequesting(true);

  const { user } = this.state;

  // This will have the value 'Done' from the worker's postMessage()
  const workerMessage = await runWorker();

  return service(user).then(result =>
    setRequesting(false);
    setUserData(result);
  )
}

And that’s it! We’ve successfully spun up a worker on its own thread, waited for it to respond with data, and then used that data in a function to make an API call.

But that Worker was Boring!

Of course, if all we needed to do was generate the string Done, there would be no real reason to do it in a worker. So let’s do something a little more interesting.

The real use case for web workers is when you need to do an expensive computation on the front-end. Since it may take several seconds to execute, the user will definitely notice if the whole UI is frozen for that long. One example of this could be generating an encryption key to send along with the request for end-to-end encryption, which may be required for an extra-secure piece of information such as a bank account or credit card number. As an example, we’ll use the cryptography package node-jose in our worker and wait for it to generate the key pair before sending the request. For this, we can write a worker like this:


const keystore = jose.JWK.createKeyStore();
const props = {
  alg: 'RSA',
  use: 'enc',
} ;
keystore.generate('RSA', 2048, props).then(keyObj => {
  self.postMessage(key.toJSON(true))
})

Remember, since we already set up webpack to load the worker into our bundle, we can use imports just like we would anywhere else. Also note that we need to convert the key to JSON before sending it, because workers require serializing all data sent back to the main thread.

To use this new worker, we can keep the same runWorker function from earlier and make some slight changes our service function:

export const doStuff = async (service) => {
  setRequesting(true);

  const { user } = this.state;

  // This is now our JSON encryption key pair
  const keyPair = await runWorker();

  // Convert the JSON key into a full JWK object
  const keyObj = await jose.JWK.asKey(keyPair);

  // Extract only the public portion of the key to send in the request
  const publicKey = keyObj.toJSON();

  return service(user, JSON.stringify(publicKey)).then(result => {
    // Use the private key in the keystore to decrypt the result
    const decryptedResult = await jose.JWE.createDecrypt(keyObj).decrypt(result);
  
    setRequesting(false);
    setUserData(decryptedResult);
  })
}

Final Considerations

Web workers are very powerful and can be an irreplaceable tool in certain circumstances. However, they do come with some of the risks and concerns associated with any multi-threaded process. The worker is completely unaware of anything happening on the main thread, so it has no idea of the current state of the site. Since something like key generation may take a long time, it’s possible that the user navigated away before it finished. To prevent unexpectedly displaying decrypted data, it may be necessary to check the state again after the worker finishes to make sure we still want to make the service call or set the user data with the result. In other words, Web Workers are a powerful tool when you need them, but they also introduce a whole extra layer of complexity to an application and should always be implemented with great care.

Join our team to work with Fortune 500 companies in solving real-world product strategy, design, and technical problems.

Find Your Role
Testing Functional Limits of Cloud-Cased NLP Services

Testing the Functional Limits of Cloud-Cased NLP Services

With the rise of intelligent assistants both on dedicated devices e.g. Alexa, Google...

Read the article