Published on

Performance, Web Workers

8 min read

At work, I was given a challenge to build a webpage using certain criteria, and I immediately started thinking about performance. The usual suspects were all there: CDN, cache, code splitting, compression, minification, tree-shaking, multiplexing, preload, defer, reconnect, SVG, WebP, AVIF, WOFF2, and more. But if I had to explain it to my wife, my definition of performance would be "I want to make the user feel good".

It was kind of funny because I was also thinking about performance while trying to build a ray tracer, but none of the standard metrics we usually use mattered. The performance was terrible and I still couldn't render the damn ball.

After spending some time crying, I realized that I was approaching every problem with a web developer's mindset, trying to solve everything in the same way. I was trapped in Maslow's hammer law: "If the only tool you have is a hammer, it is tempting to treat everything as if it were a nail."

Lesson 1

Sometimes, when I need to format a username, date, or title on any page, I choose the most beautiful function. I even organize my code based on its shape, so it can look like a Christmas tree, a ball, or a sword. The choice is yours. In this context, good performance means that the code does its job.

That's what I did with the function that parsed all the final pixel colors of the render: it had a beautiful asymmetric curve with tiny chained methods in blue. It was very neat. But it was taking forever to render a simple image when it should have been done in a few seconds.

After finding a better way to build that string, I reduced the render time by more than half. The first lesson I learned was that performance can mean different things in different contexts.

Lesson 2

132 commits later I had implemented many functionalities and my ray tracer was doing a lot of calculations. It was too slow again, and I started blaming the language, the universe, and the vaccines.

So, I took the same approach as before and started logging everything, but it was taking a long time to find the issue this time. I decided to give the Node profiler a try, and it worked. I was able to find what was happening.

The problem was this: I had a lot of spots that were doing small, harmless calculations over and over again. The code was extremely easy to read and one could understand all the steps without much effort. But that was the problem! The code was optimized for readability and not for efficiency.

In my first attempt to fix it, I cached every calculation I could, and the ray tracer was eating 8GB of RAM to render a 100-pixel square image. But after cursing JavaScript again (I mean, after one week), I stumbled upon a solution that brought the render time down to ⅓ without increasing the memory too much.

The second lesson was that small things matter if you repeat them many times (for better or worse).

Lesson 3

I no longer felt so dumb, but my engine was still much slower than what other people were building by following the same steps. My pride was a little hurt, and I thought it was the right time to try web workers.

And they worked beautifully. In the worst case, the render time went down by half. Now, I have my third lesson: it's always your fault!

The other lesson is that web workers are cool.

Web Workers

According to MDN: "Web Workers are a simple means for web content to run scripts in background threads. The worker thread can perform tasks without interfering with the user interface." That's a pretty straightforward explanation and I think everyone gets it.

signal (image from github.com/genderev/assassin)

But some things to pay attention to: Workers run a named JavaScript file. They run in another global context that is different from the current window. You can't directly manipulate the DOM from a worker.

The way I used it in my ray tracer was splitting the canvas size I wanted render into the number of threads available. Each worker has instructions to process just one part of the image. When the job is done, it returns the result to the main thread using the postMessage() function.

Here it is my worker.js file where I render the job and call postMessage() with the result:

// worker.js
onmessage = ({ data }) => {
  const { start, end, canvasSize } = data
  const { camera, world } = createScene(canvasSize)
  postMessage({ result: camera.renderPartial(world, start, end, canvasSize) })
}

And here I initialized the workers, send the message with instructions and listen for the result of each worker:

// main.js
for (let i = 0; i < navigator.hardwareConcurrency; i++) {
 workerList.push(new Worker("./worker.js"))
}

// …

const canvasSize = 120
const stepSize = Math.floor(canvasSize / navigator.hardwareConcurrency)

workerList.forEach((worker, index) => {
 const start = index * stepSize
 const end = start + stepSize

 worker.postMessage({ canvasSize, start, end })

 worker.onmessage = ({ data: { result } }) => {
   displayResultOnScreen(result, canvasCtx)
 }
}

You can see this this code in action here https://ray-tracer-javascript.web.app/?scene=1
Check out my ray tracer code: https://github.com/holive/ray-tracer

Using libraries

Having workers up and running is relatively simple, but sometimes we will need to measure if shared workers are better than dedicated ones, if the communication between the main thread and the background thread will be done by copying data or passing references, or if the architecture will scale.

Using libraries won't free you from going through these decisions (I'm sorry), but certainly will make things a little bit easier. For example, Comlink is a tiny library that helps with the communication between threads, and uses RPC to make data available to them as if it were local values.

// main.js
import * as Comlink from "https://unpkg.com/comlink/dist/esm/comlink.mjs";
async function init() {
  const worker = new Worker("worker.js");
  const obj = Comlink.wrap(worker);

  alert(`Counter: ${await obj.counter}`);
  await obj.inc();
  alert(`Counter: ${await obj.counter}`);
}
init();
// worker.js
importScripts("https://unpkg.com/comlink/dist/umd/comlink.js");

const obj = {
  counter: 0,
  inc() {
    this.counter++;
  },
};

Comlink.expose(obj);

Closing Thoughts

I've learned some interesting things going through this process. And some of them are:

  • Performance can have different meanings in different contexts.
  • Small things matter if you repeat them lots of times.
  • If something is wrong, it's always your fault! I know it sounds a coach motivational speech, but if we don't believe that we can do better we shouldn't expect that other people will do that for us.

Web workers use cases:

  • Processing data from external services;
  • Processing large arrays or JSON responses;
  • Image, sound or text processing;
  • Compute changes in a large table where many values are recomputed when one of them is changed;
  • Let the user upload a big file, read and parse it without blocking the main thread;
  • You can use Web Assembly inside of a web worker!
  • We can load and run JavaScript libraries and free the main thread of downloading and parsing this code;
  • Handle heavy downloads;
  • Stock market dashboard displaying real time data, user chat and so on;