Getting Web Workers right with Browser and Node ( react )— SSR
So if you are here you are probably trying to use WebWorkers and finding some issues, by the end of this article you’ll be able to use Web Workers in the browser and in Node server side rendering. The base project is create-react-app.
tl:dr If you just want the code that works, you can go down right into “Wrapping Up”
Why web workers?
I have built my backend systems with performance in mind, most services were built with CPP (C++) to get that juicy speed and low memory footprint, it also means I try to do as little as possible in the backend and the maximum I can in the frontend (because there are only a few server instances but thousands of client computers). It also means I try to send big messages (json files) to the frontend, to optimize the use of caches and reduce network overhead.
And all of this comes with a cost: some data processing/re-arranging in the frontend, to get the data to look/behave the best way, to make the view layer work semantically better and faster, and there is a problem:
JavaScript is slow and single-threaded by design, it also blocks the whole browser(even CSS animations) while it is doing the JS processing bit.
So anything that looks like this
let maxVal =100;
for (let i=0; i< maxVal ; i++
{
//doSomething, this is bad for(let k=0; k<maxVal; k++)
{
//doSomething2, this is even worse
}
}
Is very bad for UX ( user experience)
However JS has a thing to help with its slowness, the event loop. In simple words, JS manages what requires and doesn’t requires CPU attention, with the newest form of this management being called Promises.
Basically, anything that uses IO goes to the back of the event loop until the IO operation has returned, allowing the CPU to focus on other events, improving the performance, because the CPU is only used when the CPU is needed.
However any function that works for a ‘long time’ breaks the benefit of this architecture. Such is the simple example of code above.
Web-workers are OS level threads, that run javascript with their own contexts and can communicate with our page main thread, so if we use them right we will solve the biggest problem within browser JS, the blocking of the UI while we are processing data. We may also be able to make the data available faster, if we paralelize different parts of the data arrangement pipeline.
If we are smart we can also wrap calls to web-workers in Promises and make the Web-Worker usage derailment from usual frontend coding minimal.
Ps. there is a solution in stack overflow to stringify a function, send it to a worker, execute it and return the value through a promise, that solution is extremely slow/inefficient and should be avoided if possible.
Minimal Web Worker Setup
First of all, assuming you already have your create-react-app project install
npm install --save web-worker
This package creates a wrapper around the node specific web-worker API and makes it compatible with the browser web-worker API, to some extent.
First thing we have to understand, Web Workers are run as independent JS files in the browser, so they are not ‘obviously’ compatible with nodejs environments, however we will make this work.
We will have two parts, the WebWorker thread context JS file, and the main thread application communication part. Let’s call the WebWorker file, ‘masterWorker.js’.
Since we are using create-react-app, masterWorker.js will have to the placed in the /public folder of our app, for organization lets create a folder /workers and place masterWorker.js in it.
/public/workers/masterWorker.js
onmessage = function(messageEvent) {console.log('Received data:', messageEvent);console.log('Posting message back to main script');postMessage({messageEvent.data});}
In our application we will have to create a file that will deal with the communication with masterWorker, let’s call it workerUtils.js
/src/util/workerUtils.js
import Worker from 'web-worker';let myWorker = new Worker("/workers/masterWorker.js");
myWorker.onmessage = (messageEvent) => {console.log("Returning event:",messageEvent)};myWorker.postMessage({data:123});
If you include these files in your project, and import the workerUtils in your App.jsx, you will have a simple example that works in the browser, it will console.log some messages when the two threads exchange messages.
This setup works in the browser, but not in nodejs.
Making it work in NodeJS
The first problem is that the ‘current working directory’ in a nodejs app, is not the ./public folder, so our first fix is the following:
/src/util/workerUtils.js v2
import Worker from 'web-worker';
let basePath='';//basePath is none in the browser
if (typeof window === 'undefined')//DOM objects are undefined (node)
basePath='./public';//basepath for nodejslet myWorker = new Worker(basePath+"/workers/masterWorker.js");
// we add the basePath to the worker path, this way it will work //both in the browser and in nodejsmyWorker.onmessage = (messageEvent) => {console.log("Returning event:",messageEvent)};myWorker.postMessage({data:123});
With these few changes, you now will be able to call the worker both from NodeJs(SSR) and the browser.
There is still one problem, importing JS files/functions in masterWorker.js
The default way of importing JS files in a webworker, is through the use of the following function call:
importScript('./myOtherWorkerFile.js');//relative path to masterWorker
This function merges both files together, the caller and the called. It just ‘merges’ all the files in one, so any variable/function in any file are shared.
Nodejs will not allow the use of importScript. (it will say importScript is undefined)
Nodejs will allow the use of require and export.
The browser will not allow any require or export.
Solution 1. use just one 10 000 lines long file for all operations, no semantic problems, aside programmer hell.
Solution 2. make a way of seamlessly importing in both nodejs and the browser.
Let’s try Solution 2.
First, making it work in the browser
Suppose we want to call a specific function of a specific controller in our webworkers, let’s call the function:
‘cBook_arrangeBookTitles’.
First let’s think of the browser first, since all files will be ‘merged’ into one, we will have to deal with that.
/public/workers/masterWorker.js v2
let funcs= {}.
importScript('./BookControllerWorker.js');onmessage = function(messageEvent) {console.log('Received data:', messageEvent);console.log('Posting message back to main script');postMessage({messageEvent.data});}
And our controller worker file:
/public/workers/BookControllerWorker.js
funcs.cBook_arrangeBookTitles = function (data)
{
//doSomething
return data;
}
Here, since ‘funcs’ was defined in masterWorker.js, and BookControllerWorker.js was imported after ‘funcs’, the BookControllerWorker.js can use the variable ‘funcs’.
Now we could call that function in our onmessage event.
/public/workers/masterWorker.js v3
let funcs= {}.
importScript('./BookControllerWorker.js');onmessage = function(messageEvent) {console.log('Received data:', messageEvent);
let result = funcs['cBook_arrangeBookTitles'](messageEvent.data);console.log('Posting message back to main script');postMessage({result});}
This would work, but only in the browser, note that at this point, if we wanted the worker to execute any function of our choosing in any imported file we would only have to pass the function name and a parameters object.
/public/workers/masterWorker.js v4
let funcs= {}.
importScript('./BookControllerWorker.js');onmessage = function(messageEvent) {console.log('Received data:', messageEvent);if (typeof funcs[messageEvent.data.funcName] ==='undefined'){
postMessage({success:false});//function not found return error
return;
}let result = funcs[messageEvent.data.funcName](messageEvent.data.data);console.log('Posting message back to main script');postMessage({success:true, data:result});}
/src/util/workerUtils.js v3
import Worker from 'web-worker';
let basePath='';//basePath is none in the browser
if (typeof window === 'undefined')//DOM objects are undefined (node)
basePath='./public';//basepath for nodejslet myWorker = new Worker(basePath+"/workers/masterWorker.js");
// we add the basePath to the worker path, this way it will work //both in the browser and in nodejsmyWorker.onmessage = (messageEvent) => {console.log("Returning event:",messageEvent)};myWorker.postMessage({funcName:'cBook_arrangeBookTitles',data:123});
At this point, in the browser(only) we are able to send a message to the web-worker to execute a specific function we want and return its result.
Making it work in nodejs and the browser
So, as previously said, Node and Browser apis are incompatible, so we need to overcome that, by not executing the code that does not work in each environment and by not duplicating names that could cause issues in the future.
/public/workers/masterWorker.js v5
var funcs = {};
let myModules = [
'./BookControllerWorker.js'
];if( 'function' === typeof importScripts)
{
//BROWSERSIDE
for(let i=0;i<myModules.length;i++)
importScripts(myModules[i]);
}
else
{ //NODESIDE, SSR
let currentModule = {};
for(let i=0;i<myModules.length;i++)
{
currentModule = require(myModules[i]);
funcs = {...funcs,...currentModule.funcs};
}
}onmessage = function(messageEvent) {console.log('Received data:', messageEvent);if (typeof funcs[messageEvent.data.funcName] ==='undefined'){
postMessage({success:false});//function not found return error
return;
}let result = funcs[messageEvent.data.funcName](messageEvent.data.data);console.log('Posting message back to main script');postMessage({success:true, data:result});}
First we made a list of our imports ‘myModules’, so we can import as many files with as many functions as we want.
Then we checked if importScripts is defined, meaning we are executing at the browser and used the regular importScript there, nothing new here.
Then if we are not in the browser, we use require to acquire all exports of each file and then merge them all into one single object, ‘funcs’, the same object that we would have used in the browser.
Now for this to work, our BookController.js has to export its functions.
/public/workers/BookControllerWorker.js v2
if (typeof funcs ==='undefined')
var funcs = {}; //if we are in node, files are not merged,
//thus funcs will not exist causing issues, so we create a node-local var.
funcs.cBook_arrangeBookTitles = function (data)
{
//doSomething
return data;
}if (typeof exports !=='undefined')
exports.funcs = funcs;
//if we are in node we export the funcs object
Now if funcs doesn’t exist(we are not in the browser and didn’t get the context of the caller) we will create a local version of ‘funcs’.
If ‘exports’ is defined that means we are in node, and we add our local ‘funcs’ to the list of variables exported from our file, finally masterWorker.js will be able to import the file and its functions in both the browser and nodejs.
At this point we have an interface that works for importing in both browser and node, which is the ‘funcs’ object in masterWorker.js.
Wrapping up
Making web workers seamless with the rest of your code
At this point we have workers that function both in nodejs and the browser, but the postMessage and onMessage interfaces are too technical and detract from day to day programming, so we will fix that introducing Promises and a workerPool.
Our end result will be calling anywhere in our application:
workerDo(‘WorkerFunctionName’,data).then((resultData) =>{//doSomething})
Basically we will add an ID to executions, so that a onMessage event handler somewhere will be able to ‘router’ the result to a promise resolve.
Nothing change with our controller worker:
/public/workers/BookControllerWorker.js v2
if (typeof funcs ==='undefined')
var funcs = {}; //if we are in node, files are not merged,
//thus funcs will not exist causing issues, so we create a node-local var.funcs.cBook_arrangeBookTitles = function (data)
{
//doSomething
return data;
}if (typeof exports !=='undefined')
exports.funcs = funcs;
//if we are in node we export the funcs object
Now let’s take a look at the differences in the master worker:
/public/workers/masterWorker.js v6
var funcs = {};
let myModules = [
'./BookControllerWorker.js'
];if( 'function' === typeof importScripts)
{
//BROWSERSIDE
for(let i=0;i<myModules.length;i++)
importScripts(myModules[i]);
}
else
{ //NODESIDE, SSR
let currentModule = {};
for(let i=0;i<myModules.length;i++)
{
currentModule = require(myModules[i]);
funcs = {...funcs,...currentModule.funcs};
}
}onmessage = function(messageEvent) {
let {id,func,data} = messageEvent.data;
try
{
let retData = {};
if (typeof funcs[func] !== 'undefined')
retData = funcs[func](data);
else
{
postMessage({id:id, success:false, funcName:func,message:"Worker Function Not Found!"});
return;
}
postMessage({id:id, success:true,funcName:func, data:retData});
}catch(error)
{
postMessage({id:id, success:false, funcName:func, message:error});
}
}
In our onmessage, we receive id ,func(name) and data, we can pick the function to execute, pass it the data, and upon sending the id back to the caller we can identify which call that was.
Finally the changes to the workerUtils, we add a workerPool, a works array and functions to start worker threads and execute worker functions.
/src/util/workerUtils.js v4
import Worker from 'web-worker';
let basePath='';//basePath is none in the browser
if (typeof window === 'undefined')//DOM objects are undefined (node)
basePath='./public';//basepath for nodejslet workers = [];
let id=0;
let works = [];
let currentWorker = 0;function getWorkId(){
if (id>100)
id = 0;
id++;
return id;
}function handleReturn(messageEvent)
{
let {data} = messageEvent;
if (data.success)
works[data.id].resolve(data);
else
{
console.error("error:",data);
works[data.id].reject(data);
}
console.log('Message received from worker');
}function handleError(messageEvent)
{
console.error("unknow worker error:",messageEvent);
}export function startWorker()
{
let pos = workers.length;
workers[pos] = new Worker(basePath+"/workers/masterWorker.js");
workers[pos].onmessage = handleReturn;
workers[pos].onerror = handleError;
}
export function workerDo(funcName,workData)
{
currentWorker++;
if (currentWorker==workers.length)
currentWorker=0; let currentWorkId = getWorkId();
works[currentWorkId] = {};
let p = new Promise((resolve, reject) => {
works[currentWorkId].resolve = resolve;
works[currentWorkId].reject = reject;
});
works[currentWorkId].promise = p; workers[currentWorker].postMessage({id:currentWorkId,func:funcName,data:workData});
return works[currentWorkId].promise;
}
Many things happened here, now we have an array of workers, with a currentWorker (id) that keeps rotating from 0 to workers.length with each call of workerDo.
We have a function called startWorker(), to initialize as many workers as we may want to have for running our tasks (4 might be a good number of times to call this function)
We also have an array of ‘work’ promises, every time we call workerDo, we set an ID, save a promise with that ID in the ‘works’ array, send the ID to the worker thread, and upon return of the worker message, in handleReturn we call the promise resolve or reject method with the result.
At this point we have seamless web-workers available to use in any file of our nodejs/browser application.
Initialize once with
startWorker();
startWorker();
startWorker();
startWorker();
And execute any tasks in parallel without blocking the UI, both in nodejs and in browser with
workerDo('MyFunctionName',{mydata:123})
.then((result) => console.log(result))
.catch(error) => console.error('oh nooo', error));