Bayan Bennett

Barebones Web Workers

Gatsby JS
Webpack
JavaScript

Once there is enough of a reason to offload calculations into another thread, WebWorkers begin to shine. For me, this happened when I was creating the RNN Typing Practice app, where I was doing inference with TensorFlowJS and didn't want that heavy calculation to be handled on the main render process. The user would be trying to type a given sentence while calculations for the next sentence were made. If there was any impact on the user's typing experience, it would make the app unusable. Assuming the limit of typing speed is somewhere around 320 WPM, the amount of time between keystrokes would be:

60 secondsminuteminute320 wordsword5 characters=0.038 secondscharacter\frac{60~seconds}{minute} * \frac{minute}{320~words} * \frac{word}{5~characters} = \frac{0.038~seconds}{character}

or about 38ms per character. In that time the program must:

  • Run our keypress logic
  • Update the UI to show the current progress of the line being typed

At some point the program must calculate the next line. Originally, the app would calculate future characters between keypresses, however, some users who were faster typists noticed some performance problems with this approach.

With Workers, it became possible to calculate the next line without affecting the performance of the page.

What is a WebWorker?

It's the browser equivalent to NodeJS's Worker threads. It's code that runs in an entirely different context that is linked to the parent process by messages. A ServiceWorker is a type of WebWorker that is registered in the browser and, let's just say, has "special powers". This post will only be looking at WebWorkers, but stay tuned for a future post just on the power of Service Workers.

/* Node JS */
const path = require("path");
const { Worker } = require("worker_threads");
const workerPath = path.resolve("path", "to", "file.js");

const worker = new Worker(workerPath);

/* Browser */
const worker = new Worker("/path/to/file.js");

Worker Loader

Workers are launched with the new Worker("path/to/worker.js") constructor. This path can't be the path on the developer's computer, it must be a path that is accessible to the user when they visit the page. Fortunately, for those using webpack, there's a straightforward way to build worker files, the worker-loader. Here's how you use it:

/* webpack.config.js */
module.exports = {
  module: {
    rules: [
      {
        test: /\.worker\.js$/,
        use: {
          loader: 'worker-loader',
        },
      },
    ],
  },
};

In other words, name the file *.worker.js and worker-loader will do the rest.

GatsbyJS, provides a hook to modify the internal webpack config.

/* gatsby-node/on-create-webpack-config.js */
const webpackConfig = require("./webpack.config");
module.exports = ({ actions }) => actions.setWebpackConfig(webpackConfig);

Also, for context, this is a post on how I style my gatsby-* configuration files: Production GatsbyJS: API Files

This enables the ability to import the worker like so:

import MlKeyboardWorker from "../ml-keyboard.worker";
const mlKeyboardWorker = new MlKeyboardWorker();

Passing Messages

The reducer design pattern is a great fit for sending and handling messages. In these cases, I find it convenient to have the keys for the different actions stored somewhere accessible by any files that are sending/receiving messages. I usually use Symbols to enforce the usage of the actionTypes file and to guarantee that there will never be any collision as Symbol("my-symbol") !== Symbol("my-symbol").

/* action-types.js */
export const actionTypes = {
  main: {
    response: Symbol("main.response")
  },
  worker: {
    greet: Symbol("worker.greet")
  }
}

Here the worker is instantiated, a listener is registered, and a message is sent.

/* index.js */
import GrievousWorker from "./grievous.worker";
const grievousWorker = new GrievousWorker();

const dispatch = (action) => {
  switch(action.type){
    case actionTypes.main.response:
      console.log(action.payload);
      break;
  }  
};

grievousWorker.onmessage = ({ data: action }) => dispatch(action);

grievousWorker.postMessage({
  type: actionTypes.worker.greet,
  payload: "Hello there"
});

Here's what it looks like on the worker side. Once the greeting is received, the worker responds with the response.

/* grievous.worker.js */

const response = (payload) => {
  postMessage({
    type: actionTypes.main.response,
    payload: "General Kenobi!",
  });
}

const dispatch = (action) => {
  switch(action.type){
    case actionTypes.worker.greet:
      response(action.payload);
      break;
  }  
};

onmessage = ({ data: action }) => dispatch(action);

Final Thoughts

Workers are a terrific way to split away computationally intensive code from the main rendering thread, ideally, to make websites more responsive.

The state-reducer design pattern, popularized by the Redux state management library, is well suited to worker architecture.

Ideally, when implementing web workers, engineers will find themselves encapsulating and abstracting code, which results in an improvement in overall code quality.


Special thanks to the following people for recommending changes:

© 2022 Bayan Bennett