React: Web Workers to the rescue

Georgios Kampitakis - Dec 29 '20 - - Dev Community

One week ago I tried to create a small tool with React where I could upload an image and this would be encoded to a BlurHash string. After setting up the main functionality, previewing the original image and the blured, I moved to the encoding part. It worked but I noticed a slight issue. When the app was encoding it was becoming unresponsive and therefore until the encoding finished unusable. I tried to mitigate this issue and provide a better UX experience by adding spinners and disabling every possible interaction until the process was finished. Also the sole purpose of this tool is to do the encoding so you don't expect to do something other than that in the meantime.

Alt Text

But this made me curious, how could I tackle this issue, what if in the future I wanted to add another feature in my app where the user wanted to interract with my application while it was doing some heavy calculations? And here come the Web Workers. I' ll try to explain how it worked for me in the context of React and CRA (Create React App) and how it helped me solve my problem.

What is a Web Worker

Quoting from MDN docs:

"Web Workers are a simple means for web content to run scripts in background threads."

Javascript is single-threaded, meaning it has only one call stack and one memory heap, it executes code in order and must finish executing a piece of code before moving to the next one. So this is where the problem lies, that until the encoding of the image finishes the UI can't execute any other "piece" of code. So if we can move the responsibility of encoding to a Web Worker the main thread will be free to handle user inputs.

Setup React App

If you are using CRA for starting your project you need firstly to do some steps as CRA has no "native" support for Web Workers.

In order to use Web Workers we need to update our webpack config and add worker-loader, but tweaking webpack in apps created with CRA is not possible without using react-app-rewired a module that gives you the ability to

"Tweak the create-react-app webpack config(s) without using 'eject' and without creating a fork of the react-scripts."

So we install both of those dependencies and then we create a file config-overrides.js where we can override webpack and add worker-loader.

module.exports = function override (config, env) {
  config.module.rules.push({
    test: /\.worker\.js$/,
    use: { loader: 'worker-loader' }
  })
  return config;
}
Enter fullscreen mode Exit fullscreen mode

| Keep in mind your Web Worker script needs to have a name on .worker.js format.

Finally we need to make sure our package.json scripts call react-app-rewired instead of react-scripts

"scripts": {
  "start": "react-app-rewired start",
  "build": "react-app-rewired build",
  ...
}
Enter fullscreen mode Exit fullscreen mode

Now you are ready to use Web Workers in a React app created with CRA.

How it looked

So let's try and have a look on some code and how to solve the issue of blocking UI during heave calculations.

My code looked something like this

useEffect(()=>{
...
  encodeImageToBlurhash(url,x,y)
    .then()
    .catch();
...
},[url,x,y]);
Enter fullscreen mode Exit fullscreen mode

and the encodeImageToBlurhash was loading an image from a canvas and calling the "costly" encode function.

async function encodeImageToBlurhash (imageUrl,x,y) {
  const image = await loadImage(imageUrl);
  const imageData = getImageData(image);
  return encode(imageData.data, imageData.width, imageData.height, x, y);
};
Enter fullscreen mode Exit fullscreen mode

Refactoring

After the refactoring my code looked like


useEffect(()=>{
 let worker;

 async function wrapper() {
    worker = new EncodeWorker();

    worker.addEventListener('message', (e)=> {
      const { hash } = e.data;
      ...
    });

    worker.addEventListener('error', e => {
      console.error(e);
      ...
    });

    const [data, width, height] = await 
    encodeImageToBlurhash(url);

    worker.postMessage({ payload: { data, width, height, x, y } 
    });
  }

  wrapper();

  return () => { if(worker) worker.terminate();}
},[...]);

Enter fullscreen mode Exit fullscreen mode

and the encodeImageToBlurhash just returns the image data now

async function encodeImageToBlurhash (imageUrl) {
  const image = await loadImage(imageUrl);
  const imageData = getImageData(image);
  return [imageData.data, imageData.width, imageData.height];
};
Enter fullscreen mode Exit fullscreen mode

A lot of code here but I am going to explain.

So useEffect changed and now:

  • Creates a Web Worker,
  • Added listeners for error and message, as Web Workers communicate with the code that created them with event handlers and posting messages,
  • Call the encodeImageToBlurhash to get the image data,
  • call the "costly" encode function from inside the Web Worker by posting the image data in order to start the calculations
  • and finally terminate the Web Worker.

Our Web Worker is not really complicated

const ctx = self;
const { encode } = require('blurhash');

ctx.addEventListener("message", (event) => {
  const { payload } = event.data;
  const hash = encode(payload.data, payload.width, payload.height, payload.x, payload.y);
  ctx.postMessage({ hash });
});
Enter fullscreen mode Exit fullscreen mode

as it's just listens for a message and starts encoding the image data and after it finishes reports back with the resulting string.

Result

Now the result is every time we do a calculation we create a Web Worker that runs on a different thread and leaves the main thread, where UI runs unblocked and ready to accept user input.

Alt Text

and as you can notice now we have the loaded Web Worker, and a second thread running other than Main.

Resources

. . . . . . . . . . . . . . .