Jason Thorsness

github
github icon
linkedin
linkedin icon
twitter
twitter icon
hi
3Feb 25, 24

Hire Some Web Workers

A Long-Known Pattern

One of the easiest ways to ruin the perception of performance of a system is to have it respond slowly to user input. Command line, GUI, it doesn’t matter — a lagging cursor, pointer, or slow response of UI element gives the impression that a system is struggling and is slow.

A Straightforward Solution

The typical pattern for avoiding this is the make sure that the part of your system responsible for receiving user input and updating UI elements has no other work to do. However, in many systems, including in browser JavaScript, by default everything is performed by the same thread — even long-running computationally-intensive tasks. Luckily, for over ten years Web Workers have been available to move work off the main thread. Using them is easy — read on to see how I fixed the sluggish slider from my previous article.

When Does The Problem Occur?

JavaScript environments like browsers are very good at asynchronous concurrency. While waiting for network IO or other external operations using promises or callbacks the main thread is not blocked and the UI will not seem sluggish. Only when a function takes a lot of CPU time without yielding will responsiveness suffer. So it might not make sense to perform network IO via a Web Worker, but an operation that takes a lot of CPU time by nature, like decompressing a file or manipulating an image, is a good candidate for a Web Worker.

What About NextJS?

NextJS has an example project demonstrating use of Web Workers. The steps are basically:

  1. Create a worker file. I created one called “example1.worker1.ts” in the same directly as my previous article.
  2. Reference the worker file with useRef.
  3. Communicate with the worker using postMessage and onmessage.

Here is the code for the worker file. Note that when the Emscripten runtime is initialized, after it adds an event listener it posts a message back to the main thread. This lets the main thread know when it can start sending messages.

import "components/wasm/loader";
Module.onRuntimeInitialized = () => {
  addEventListener("message", (event: MessageEvent<number>) => {
    self._Init();
    const size = event.data * 1024;
    const buffer = self._Alloc(size);
    const start = performance.now();
    for (let i = 0; i < 999; ++i) {
      self._BitwiseComplement(buffer, size);
    }
    const end = performance.now();
    self._Free(buffer);
    postMessage({ value: event.data, time: end - start });
  });
  postMessage({ value: 0, time: 0 });
};

Tell Me About the Loader

I struggled a bit with the interface between Emscripten’s generated wasm.js and the wasm.wasm file. The generated JavaScript wants to look for the wasm blob in the same directory as the worker script, which was one of the webpack chunks folders. After a rabbit hole of attempts, I found the simplest solution was to edit Emscripten’s generated wasm.js to load the wasm blob from a custom path. I also added the wasm files as assets in the webpack config, rather than serve them from /public, so they would get version hashes and NextJS would cache them more effectively.

Here is the webpack config. Note that the input file is renamed “wasm.jsbin” to avoid webpack’s default handling of JS files. It’s renamed back to .js by the generator.

config.module.rules = [
  {
    test: /wasm.jsbin$/,
    type: "asset/resource",
    generator: {
      filename: "static/media/[hash:8].[name].js",
    },
  },
  {
    test: /wasm.wasm$/,
    type: "asset/resource",
  },
].concat(config.module.rules);

Now in the loader.ts file, the above can be referenced to get the actual paths.

declare var Module: { onRuntimeInitialized: () => void };
const wasmJSPath: string = require("./wasm.jsbin");
const wasmPath: string = require("./wasm.wasm");
self.wasmPath = wasmPath;
self.importScripts(wasmJSPath);

In the Emscripten generated wasm.js file, the wasmPath can be used to load the wasm blob, replacing the ‘look in same folder’ logic.

// EDIT
var wasmBinaryFile = self.wasmPath;

With this approach the worker loads the wasm correctly and NextJS serves it with good cache-control headers.

Deduplicating Slider Values

If you post a message to the Web Worker every time the slider value changes, it quickly creates a long queue of messages. This wastes CPU because the user really only cares about the final resting position of the slider. To avoid this, I used a ‘busy’ flag to avoid posting new messages to a worker that was busy. Whenever the worker finished, if the value it had run for was not the final value, a new message was pushed. The below shows the worker management code for both the SIMD and non-SIMD workers. Note that the default “worker busy” is true — this is because we expect the worker to post an initial message when it is ready to accept requests.

const [worker1Result, setWorker1Result] = useState({ value: 0, time: 0 });
const [worker2Result, setWorker2Result] = useState({ value: 0, time: 0 });

const worker1BusyRef = useRef(true);
const worker2BusyRef = useRef(true);

const worker1Ref = useRef<Worker>();
const worker2Ref = useRef<Worker>();

useEffect(() => {
  worker1Ref.current = new Worker(new URL("./example1.worker1.ts", import.meta.url));
  worker2Ref.current = new Worker(new URL("./example1.worker2.ts", import.meta.url));

  worker1Ref.current.onmessage = (event: MessageEvent<WorkItem>) => {
    worker1BusyRef.current = false;
    setWorker1Result(event.data);
  };

  worker2Ref.current.onmessage = (event: MessageEvent<WorkItem>) => {
    worker2BusyRef.current = false;
    setWorker2Result(event.data);
  };

  return () => {
    worker1Ref.current?.terminate();
    worker2Ref.current?.terminate();
  };
}, []);

useEffect(() => {
  if (worker1Ref.current == null || worker1BusyRef.current) {
    return;
  }
  if (worker1Result.value !== value) {
    worker1BusyRef.current = true;
    worker1Ref.current?.postMessage(value);
  }
}, [worker1Ref, value, worker1Result]);

useEffect(() => {
  if (worker2Ref.current == null || worker2BusyRef.current) {
    return;
  }
  if (worker2Result.value !== value) {
    worker2BusyRef.current = true;
    worker2Ref.current?.postMessage(value);
  }
}, [worker2Ref, value, worker2Result]);

The Results

New when you drag this slider, the slider remains responsive as the expensive calculations are done on background threads.

Input
100 KiB
Without SIMD
??? ms
With SIMD
??? ms
Speed Up
???x Faster with SIMD
 Top