Skip to main content

Efficient communication for web workers

Β· 6 min read
Lalit Dibyadarshan
Front End Engineer @ Thoughtworks

Efficient communication for web workers​

Hello readers, welcome to my new article on web worker communications. I hope you enjoyed my previous article on multi threading on the web. In this article, we will discuss how to scale web workers and handle worker responses more efficiently.

[ Quick refresher ]

**Creating Web worker - ** > const worker = new Worker("./worker/filepath"); >
**Sending data to web worker - ** > worker.postMessage(data, buffer?);
**Getting data from web worker - ** > worker.onmessage = (data) => { /* logic*/ }

Scaling problem​

In the previous article, we discussed how to offload a heavy thread-blocking task to web workers. But in real production environments, we might have multiple such tasks that we need to offload. In this segment, we will discuss how to handle them gracefully.

Let's consider we have 2 heavy functions:

  1. Find the Fibonacci series for the first 100000000000 numbers
  2. Sum of first 100000000000 numbers

So, what should be our approach πŸ€” ?? . . .

An Okay-ish Solution​

After thinking for an eternity, I decided to exploit the following principles in a genius way

Let's create two web workers for both functions and problem solved 😎. job done bean.gif

Awesome !!! But now we got another requirement, where we need to add 3 more heavy functions (5 in total).

My lazy brain immediately said: No problem create 3 more web workersπŸ˜‡.

But deep inside, I knew how dumb this idea is. The whole point to use web worker is to improve performance. Web workers internally use actual threads on OS. If we open these many workers at the same time, it will use the CPU heavily and have a negative impact on performance.

Ok, Now let's think again... But this time with a single web worker.

A Better Solution​

Have you got any lead on how to proceed? If Yes, please give it try before proceeding to the solution part. In case you have not figured out any approach, let's do it together.

What if we can define all the functions in a single web worker and based on incoming data, the web worker will figure out which method to invoke and call them with proper inputs? Confused ?? Let's implement it have some clarity.

// Worker.js

const works = {
heavyWorkOne: (param) => {
/* some heavy work */
},
heavyWorkTwo: (param) => {
/* some heavy work */
},
heavyWorkThree: (param) => {
/* some heavy work */
},
heavyWorkFour: (param) => {
/* some heavy work */
},
heavyWorkFive: (param) => {
/* some heavy work */
},
};

self.onmessage = (data) => {
const { methodName, input } = data;
const args = Array.isArray(input) ? input : Array.of(input);
const result = works[methodName].apply(this, args);
self.postMessage(result);
};

The clean and clear solution, right? But there it one problem here. As the methods increase, file size also increases. Which will make it hard to maintain the file.

Can we create separate files for each method and import them into the worker files? Can we use ES import in workers?

The answer is Yes βœ…

const worker = new Worker('./path/to/worker', { type: 'module' });

If we specify, type as module , while creating web worker, then it will allow us to use es imports.

// Worker.js
import heavyWorkOne from './heavyWorkOne';
import heavyWorkTwo from './heavyWorkTwo';
import heavyWorkThree from './heavyWorkThree';
import heavyWorkFour from './heavyWorkFour';
import heavyWorkFive from './heavyWorkFive';

const works = {
heavyWorkOne,
heavyWorkTwo,
heavyWorkThree,
heavyWorkFour,
heavyWorkFive,
};

self.onmessage = (data) => {
const { methodName, input } = data;
const args = Array.isArray(input) ? input : Array.of(input);
const result = works[methodName].apply(this, args);
self.postMessage(result);
};

Now it is more scalable and easier to add new functionalities to it.

Communication Problem​

Excellent, now that our worker is ready, let's give it a try.

const worker = new Worker('./path/to/worker', { type: 'module' });
const inputEl = document.getElementById('dummyInput');

inputEl.addEventListener('click', () => {
worker.postMessage({ methodName: 'heavyWorkOne', data: 100000000 });
worker.onmessage((result) => {
console.log(result);
});
});

inputEl.addEventListener('change', () => {
worker.postMessage({ methodName: 'heavyWorkOne', data: 100000 });
worker.onmessage((result) => {
console.log(result);
});
});

inputEl.addEventListener('keyup', () => {
worker.postMessage({ methodName: 'heavyWorkFour', data: 100000000 });
worker.onmessage((result) => {
console.log(result);
});
});

inputEl.addEventListener('blur', () => {
worker.postMessage({ methodName: 'heavyWorkFive', data: 100000000 });
worker.onmessage((result) => {
console.log(result);
});
});

And here we go.. another bunch of problems.

  1. Code duplication
  2. multiple listeners
  3. All four onmessage will be notified for the results others message. We can not figure out which result came for which message. (Major problem)

![multiple calls.gif](https://cdn.hashnode.com/res/hashnode/image/upload/v1660503635703/dtggRg2D6.gif align="left")

Communication Layer between worker and main js​

To Solve the above issues, we will create an intermediate layer. This layer will

  • create web worker
  • handle onmessage event
  • return appropriate results to the respected message sender
class CommunicationWorker {
constructor(workerFilePath, options) {
this.worker = new Worker(workerFilePath, options);
this.worker.onmessage = this.messageHandler.bind(this);
this.activeCalls = new Map();
}

send(data) {
const id = Math.round(Math.random() * 100000);
let resolve, reject;
const promise = new Promise((res, rej) => {
resolve = res;
reject = rej;
});
this.activeCalls.set(id, [resolve, reject]);

this.worker.postMessage({
id,
...data,
});

return promise;
}

messageHandler(e) {
const { error, id } = e.data;
const [resolve, reject] = this.activeCalls.get(id);
if (error) {
reject(e.data);
} else {
resolve(e.data);
}
this.activeCalls.delete(id);
}
}

Let's update our previous code using this layer.

const worker = new CommunicationWorker('./path/to/worker', { type: 'module' });
const inputEl = document.getElementById('dummyInput');

inputEl.addEventListener('click', async () => {
const result = await worker.send({
methodName: 'heavyWorkOne',
data: 100000000,
});
});

inputEl.addEventListener('change', async () => {
const result = await worker.send({
methodName: 'heavyWorkOne',
data: 100000,
});
});

inputEl.addEventListener('keyup', async () => {
const result = await worker.send({
methodName: 'heavyWorkFour',
data: 100000000,
});
});

inputEl.addEventListener('blur', async () => {
const result = await worker.send({
methodName: 'heavyWorkFive',
data: 100000000,
});
});

Voila !! We not only fixed all the above problems but also promisified the process. Now the code is much more readable and maintainable.

I hope, it will help you in your development journey, If you have any better ideas, please share them in the comments. It will be great learning for me and our community.

Thank you so much for reading my article. If you find it useful, please give your reactions. I will be happy to receive feedback.

Have a great day ahead.