Home | Send Feedback

Web Workers - Basic usage, Angular and Comlink

Published: 31. July 2019  •  javascript

In this blog post, we are going to explore the Web Workers API. First, we take a look at the problem and why we need Web Workers. Then we create a few examples with Web Workers in plain JavaScript.
In the second part of this blog post, we take a look at the Angular build time support for Web Workers, and lastly, we check out the Comlink JavaScript library that simplifies the Web Worker programming model.

You find the source code for all the examples on my GitHub account:
https://github.com/ralscha/blog2019/tree/master/webworkers

Problem

JavaScript applications run in a single-threaded event loop. We don't have to worry about any concurrency and locking issues because our code always runs in a single thread. This also means we need to make sure that our application never blocks this event loop because that would prevent the application from receiving any asynchronous callbacks, and it stops the GUI from updating. It essentially freezes the browser tab.

See the MDN page for more information about the EventLoop:
https://developer.mozilla.org/en-US/docs/Web/JavaScript/Event_loop

But what if we have no choice and we have to run a time-consuming algorithm in our application. The following application runs the Mandelbrot set algorithm and draws the famous Mandelbrot set into a canvas.

const myCanvas = document.getElementById('myCanvas');
const ctx = myCanvas.getContext('2d');
ctx.fillStyle = 'black';

const timeElement = document.getElementById('time');
const progressElement = document.getElementById('progress');
let progress = 0;

const height = myCanvas.height;
const width = myCanvas.width;
const maxIteration = 20000;
const totalPixels = height * width;

document.getElementById('startButton').addEventListener('click', () => {
  progressElement.innerText = '0 %';
  ctx.clearRect(0, 0, myCanvas.width, myCanvas.height);
  timeElement.innerText = 'working...';
  performance.clearMarks();
  performance.clearMeasures();
  performance.mark('start-mandelbrot');

  drawMandelbrotSet();

  performance.mark('end-mandelbrot');
  performance.measure('mandelbrot', 'start-mandelbrot', 'end-mandelbrot');
  timeElement.innerText = performance.getEntriesByName('mandelbrot')[0].duration + 'ms';
  progressElement.innerText = '100 %';
});

document.getElementById('clearButton').addEventListener('click', () => {
  progressElement.innerText = '0 %';
  timeElement.innerHTML = ' ';
  ctx.clearRect(0, 0, myCanvas.width, myCanvas.height);
});

function drawMandelbrotSet() {
  for (let iy = 0; iy < height; iy++) {
    for (let ix = 0; ix < width; ix++) {
      const cRe = (ix - width / 2.0) * 4.0 / width;
      const cIm = (iy - height / 2.0) * 4.0 / width;
      let x = 0;
      let y = 0;

      let iteration = 0;
      while (x * x + y * y <= 4 && iteration < maxIteration) {
        const xNew = x * x - y * y + cRe;
        y = 2 * x * y + cIm;
        x = xNew;
        iteration++;
      }
      if (iteration >= maxIteration) {
        ctx.fillRect(ix, iy, 1, 1);
      }
      progress++;
    }
    progressElement.innerText = progress * 100 / totalPixels + ' %';
  }
}

main.js

This algorithm runs for a few seconds and blocks during that time the event loop. We immediately see the effect of this blocking; the GUI stops updating. The start button stays in the pressed state, the progress text is not updated, and we don't see anything on the canvas.

As soon as the algorithm is finished, the event loop is getting unblocked, and the GUI updates. We see the Mandelbrot set appears on the screen and the progress field updates.

blocking

Web Workers

We have seen the problem; now, the question is, how can we solve it. Fortunately, the JavaScript environment has a solution for this: Web Workers API.

Web Workers allow a JavaScript application to run code in background threads. These worker threads can perform tasks without blocking the event loop.

Visit the MDN page for more information about Web Workers:
https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Using_web_workers


Basic Usage

Let's start with a simple example. We create a worker that adds two numbers together. First, we create two JavaScript files: main.js and worker.js. The code for a Web Worker needs to be loaded from a separate file.

The code in main.js runs on the main thread creates and starts the Web Worker. You create a Web Worker by creating a new Worker object and pass the path to a JavaScript file as the argument. The code in this JavaScript runs in a background thread.

const worker = new Worker('worker.js');
worker.addEventListener('message', message => {
  console.log('result: ' + message.data);
});

worker.postMessage({ a: 1, b: 2 });
worker.postMessage({ a: 3, b: 6 });
worker.postMessage({ a: 4, b: 4 });

main.js

self.addEventListener('message', messageEvent => {
  const data = messageEvent.data;
  const result = data.a + data.b;
  postMessage(result);
});

worker.js

You can run any code in a Web Worker, with some exceptions. The most significant limitation is that you can't access the DOM from a Web Worker. Also, other APIs, like the Web Storage API, are not available from the Web Worker context.

MDN maintains a list of APIs that are available to Web Workers:
https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Functions_and_classes_available_to_workers

For example, you can issue HTTP requests, open WebSocket connections, and access IndexedDB from a Web Worker. In the last part of this blog post, I'm going to show you an example that fetches resources from a network and insert them into IndexedDB.

It's important to note that workers and the main application run in different contexts. Thus, Web Workers don't have access to the code in the main application and vice versa. The only way to exchange data between the main application and a Web Worker is with the messaging system.

Both sides can send messages with postMessage(), and both sides can listen to messages by registering a listener for the message event. The payload of the message is contained in the data attribute of the message event object.

The data we pass to the postMessage() method is by default copied. We will later see an example where we pass an object by reference. This is useful for applications that need to exchange huge messages, for example, a Web Worker that runs image manipulations where the application passes a whole image to a Web Worker. Copying the message, in this case, would be detrimental to performance and memory usage.

In our simple application, you see that both the main application and the Web Worker register, a message event listener.

The main application then sends three messages with postMessage() to the worker. The worker receives these events one by one, adds the two numbers together and returns the result with postMessage() back to the main thread.

The application on the main thread receives the result in the message event handler and prints the result into the console.

It's worth noting that you are not limited to just one Worker. You can instantiate multiple Worker objects with the same code, and you can create multiple Worker objects with different code. In the next section, we will see an example that creates multiple Worker objects with the same code.


The Web Worker interface also provides the worker.terminate() method to immediately terminate a worker. This method stops the worker and does not wait until the worker finished its operations. You can no longer use this worker and need to instantiate a new Worker object if you want to run the code again.


Make sure, when you play with Web Workers, that you serve your HTML pages from an HTTP server. Web Workers don't work when you load a page with the file:// scheme. My preferred solution for non-Angular examples is by installing the local-web-server package and then create an npm script.

{
  "name": "basic",
  "version": "1.0.0",
  "scripts": {
    "start": "ws -d . -p 1234"
  },
  "devDependencies": {
    "local-web-server": "5.3.0"
  }
}

package.json

With this package.json in place, I can start the server with npm start, and he serves everything from the local directory.


Run Mandelbrot in a Web Worker

Back to our initial Mandelbrot set example. We want to run the code in a Web Worker. First, we need to create a new JavaScript file that contains the Mandelbrot set algorithm. Then we need to register handlers for the message event.

On the Web Worker side, the message event contains the width, height, and maxIteration parameters, that the main application sent. After the Web Worker receives the event, he starts the algorithm.

self.addEventListener('message', messageEvent => {
  computeMandelbrotSet(messageEvent.data);
});

function computeMandelbrotSet({ width, height, maxIteration }) {
  for (let iy = 0; iy < height; iy++) {
    for (let ix = 0; ix < width; ix++) {
      const cRe = (ix - width / 2.0) * 4.0 / width;
      const cIm = (iy - height / 2.0) * 4.0 / width;
      let x = 0;
      let y = 0;

      let iteration = 0;
      while (x * x + y * y <= 4 && iteration < maxIteration) {
        const xNew = x * x - y * y + cRe;
        y = 2 * x * y + cIm;
        x = xNew;
        iteration++;
      }
      if (iteration >= maxIteration) {
        postMessage([ix, iy]);
      }
    }
  }
  postMessage(null);
}



mandelbrot.js

Remember that we can't update the DOM and thus the canvas from the worker, so we have to send back the results with postMessage() calls. In this version, we do this for each pixel that is part of the Mandelbrot set. The last postMessage() with the argument null signifies the end of the computation.


In the main application, we call postMessage(), the counterpart to the message event listener above. Here we pass the height, width and maxIteration parameters to the Web Worker.

  worker.postMessage({ height, width, maxIteration });

main.js

Before we can use the worker we need to create it with new and register an event listener that receives the results from the background thread.

In this listener, the application draws the pixels onto the canvas with fillRect().

When the worker sends back a null response, the application knows that the computation is finished.

const worker = new Worker('mandelbrot.js');
worker.addEventListener('message', message => {
  const data = message.data;
  if (data !== null) {
    ctx.fillRect(data[0], data[1], 1, 1);
    progress = data[0] + (data[1] * width);
    progressElement.innerText = Math.round(progress * 100 / totalPixels) + ' %';
  } else {
    performance.mark('end-mandelbrot');
    performance.measure('mandelbrot', 'start-mandelbrot', 'end-mandelbrot');
    timeElement.innerText = performance.getEntriesByName('mandelbrot')[0].duration + 'ms';
    progressElement.innerText = '100 %';
  }
});

main.js

This does work; we no longer block the event loop, but we also see that this does not run very smoothly, and the algorithm takes longer than the first blocking example. The reason for this is that we send a lot of messages from the worker thread to our main thread. The overhead of the messaging systems increases the run time in this example.

To solve this issue, we have to decrease the number of messages we send from the worker. In this particular case, this is very easy to achieve. Instead of calling postMessage() for each pixel, we collect the pixels in an array. When the array contains 1000 elements, we send it with a postMessage() call to the main application

function computeMandelbrotSet({ width, height, maxIteration }) {
  let batch = [];
  for (let iy = 0; iy < height; iy++) {
    for (let ix = 0; ix < width; ix++) {
      const cRe = (ix - width / 2.0) * 4.0 / width;
      const cIm = (iy - height / 2.0) * 4.0 / width;
      let x = 0;
      let y = 0;

      let iteration = 0;
      while (x * x + y * y <= 4 && iteration < maxIteration) {
        const xNew = x * x - y * y + cRe;
        y = 2 * x * y + cIm;
        x = xNew;
        iteration++;
      }
      if (iteration >= maxIteration) {
        batch.push([ix, iy]);
        if (batch.length === 1000) {
          postMessage(batch);
          batch = [];
        }
      }
    }
  }

  if (batch.length > 0) {
    postMessage(batch);
  }

  postMessage(null);
}

mandelbrot-batch.js

In the main application, we have to adjust the code that draws the pixels onto the canvas.

worker.addEventListener('message', message => {
  const data = message.data;
  if (data !== null) {
    for (let i = 0; i < data.length; i++) {
      ctx.fillRect(data[i][0], data[i][1], 1, 1);
    }

main-batch.js

This version of the applications now runs much smoother and takes about the same time as the initial blocking version.

non-blocking


Multiple Web Workers

So far, we only create one Worker. The Mandelbrot set algorithm is an algorithm that runs the calculation for each pixel independently from all the other pixels. The algorithm does not need information about the surrounding pixels, and it does not hold any state across the whole calculation. For these reasons, we can efficiently parallelize the algorithm and start multiple Web Workers. This should increase the performance significantly.

In the following example, the application creates four Workers; they all load the same JavaScript code (mandelbrot-multi.js).

const workers = [];

for (let w = 0; w < numberOfWorkers; w++) {
  workers[w] = new Worker('mandelbrot-multi.js');

main-multi.js

I'm using a straightforward approach here. I divide the canvas into quadrants of 100 by 100 pixels. The main application creates the Worker objects and then starts the calculation by assigning a quadrant to each of the four Web Workers.

  for (let w = 0; w < numberOfWorkers; w++) {
    workers[w].postMessage({
      startX: workX,
      startY: workY,
      width: 100,
      height: 100,
      totalWidth: width,
      totalHeight: height,
      maxIteration
    });
    workX += 100;
    if (workX === width) {
      workY += 100;
      workX = 0;
    }
  }

main-multi.js

The global variables workX and workY keep track of the quadrants. Each worker runs the algorithm for their quadrant in parallel and sends back the result with postMessage()

self.addEventListener('message', messageEvent => {
  computeMandelbrotSet(messageEvent.data);
});

function computeMandelbrotSet({ startX, startY, width, height, totalWidth, totalHeight, maxIteration }) {
  const result = [];
  for (let iy = startY; iy < startY + height; iy++) {
    for (let ix = startX; ix < startX + width; ix++) {
      const cRe = (ix - totalWidth / 2.0) * 4.0 / totalWidth;
      const cIm = (iy - totalHeight / 2.0) * 4.0 / totalWidth;
      let x = 0;
      let y = 0;

      let iteration = 0;
      while (x * x + y * y <= 4 && iteration < maxIteration) {
        const xNew = x * x - y * y + cRe;
        y = 2 * x * y + cIm;
        x = xNew;
        iteration++;
      }
      if (iteration >= maxIteration) {
        result.push([ix, iy]);
      }
    }
  }
  postMessage(result);
}

mandelbrot-multi.js


In the main application, each of the four workers has a message handler attached to it. Each time one worker finishes his job he sends back a message which in turn then calls the corresponding listener.

  workers[w].addEventListener('message', message => {
    const data = message.data;
    for (let i = 0; i < data.length; i++) {
      ctx.fillRect(data[i][0], data[i][1], 1, 1);
    }

    const last = data[data.length - 1];
    if (last) {
      progress = last[0] + (last[1] * width);
      progressElement.innerText = Math.round(progress * 100 / totalPixels) + ' %';
    }

    if (workY < height) {
      workers[w].postMessage({
        startX: workX,
        startY: workY,
        width: 100,
        height: 100,
        totalWidth: width,
        totalHeight: height,
        maxIteration
      });

      workX += 100;
      if (workX === width) {
        workY += 100;
        workX = 0;
      }
    } else {
      endCounter++;
      if (endCounter === numberOfWorkers) {
        performance.mark('end-mandelbrot');
        performance.measure('mandelbrot', 'start-mandelbrot', 'end-mandelbrot');
        timeElement.innerText = performance.getEntriesByName('mandelbrot')[0].duration + 'ms';
        progressElement.innerText = '100 %';
      }
    }
  });

main-multi.js

In the event handler the application draws the pixels onto the canvas and checks with the global variables workX and workY if there are more quadrants available. If there is more work available, it sends a message to the worker with the coordinates of the next quadrant.

We don't have to worry about concurrency issue because our main application runs in the single-threaded event loop, so the message handler will never run multiple times at the same time. The messages will be handled one after the other.

If there are no more quadrants available, the application increments the endCounter variable. If this variable is equal to the number of workers, we know that all workers have finished their work, and the application prints out the elapsed time.


Transfer messages

As mentioned before, arguments you pass to postMessge() will be copied. In these examples, this is not a problem, even our 1000 element array is not that big, and each element only contains two numbers. But when the messages are getting bigger, the overhead of copying the arguments increases the run time and memory usage.

For these cases, the postMessage() supports a second optional parameter called transfer. This parameter must be an array of objects that the method should transfer as reference rather than copying it. Transferring a reference also has the effect that the ownership of the object changes to the receiver of the message. The sender can no longer access these objects.

Only instances of ArrayBuffer, MessagePort and ImageBitmap can be transferred this way. To demonstrate this behavior, the following example shows a modified version of the previous example where the worker wraps the result array into a Uint16Array object and then send it to the main application. As the second argument of postMessage(), I specify here the buffer property, which is the ArrayBuffer part of the Uint16Array object. Because of this second argument, buffer will no longer be copied; instead, the reference to the object is transferred to the receiver.

  const message = new Uint16Array(result);
  console.log('before', message.buffer.byteLength);
  postMessage(message, [message.buffer]);
  console.log('after', message.buffer.byteLength);

mandelbrot-multi-transfer.js

With the log statements, we can observe the change of ownership. After the postMessage() call, the length of the ArrayBuffer object is 0. The owner of the object is now the receiver of the message.

In this application, we don't see any difference in performance if we copy the message or send a reference. It uses a bit less memory because the application no longer has to allocate memory for the copy, but that is almost neglectable in this application.

The code in the main application that receives the message also has to change because it now receives a Uint16Array object. The application utilizes an iterator to loop over all x/y coordinates.

  workers[w].addEventListener('message', message => {
    const data = message.data;
    const arrayIterator = data.values();
    let result = arrayIterator.next();
    let x;
    let y;
    while (!result.done) {
      x = result.value;
      y = arrayIterator.next().value;
      ctx.fillRect(x, y, 1, 1);
      result = arrayIterator.next();
    }

main-multi-transfer.js

Angular support for Web Workers

After the basic introduction, let's shift focus to a new feature introduced in Angular 8. With Angular 8, it has now become much easier to work with Web Workers. The programming model does not change but Angular 8 introduced build time support that takes care of bundling the Web Worker code.

This is one of the challenges of the Web Worker development where you have to bundle the code into its own file. In the examples we have seen so far this is not a big problem, but as soon as you start importing 3rd party libraries into your Web Worker code, you need to set up a build system that takes care of bundling all the libraries into one file.

With the new build-time support in Angular 8, it's getting very easy to write Web Worker code. You import 3rd party libraries and write your code in TypeScript, and the Angular CLI takes care of the rest.

You can add the Web Worker support to any Angular application, that has been created with ng new. To create a Web Worker, you issue this command: ng generate webWorker mandelbrot
The last argument is the name of the web worker file. The generate command adds .worker.ts to the name. In this example, the command creates the file mandelbrot.worker.ts in the src folder.

The first time you run this command, the CLI not only creates the Web Worker file, but it also updates angular.json and creates tsconfig.worker.json.

Subsequent calls of the generate command only create a new Web Worker file. You can create multiple worker source files, make sure that you give each a unique name.


You find an Angular version of the Mandelbrot set example here:
https://github.com/ralscha/blog2019/tree/master/webworkers/angular/src/app

The code is almost the same as the plain JavaScript version, with one crucial difference. When we create the Worker object, we need to pass the type of the Web Worker as an option in the second argument.

      this.workers[w] = new Worker(new URL('./mandelbrot.worker', import.meta.url), {type: 'module'});

app.component.ts

The type value is either classic or module. If not specified, the default used is classic. This is also what we used so far, which specifies a pure JavaScript file. But in this case, the Angular CLI builds a module, and we have to load it accordingly.

Also, notice that you don't specify the suffix .ts in the path. TypeScript code would not run in a browser anyway and needs to be transpiled to JavaScript first. The CLI automatically adjust the name of the Web Worker when you build the application.

In all the examples so far, you have seen that setting up the messaging infrastructure requires a bit of coding. In these examples, it was not a big deal because the messages the application sends are very trivial, but still, we have to register listeners on both sides, and we have to send messages with postMessage(). The complexity increases when you exchange multiple types of messages.

There are many JavaScript libraries available that simplify this programming model. In this blog post we take a closer look at Comlink

Comlink is a tiny library (1.1 KB) that completely hides the messaging infrastructure behind proxy objects and instead exposes your worker code as an asynchronous API that returns Promises.

Notice that Comlink requires a browser that supports the ECMAScript 2015 proxy feature (https://caniuse.com/#search=proxy). If you need to support browser without built-in proxy support (IE11) you have to add a polyfill: proxy-polyfill


Let's start with a plain Web Worker example. In this demo application, we calculate the Fibonacci sequence and return the number at the requested index.

Like in all the examples before, we create a Worker object and register event handlers in the main application and the worker. Lastly, we kick off the calculation with a postMessage() call from the main application.

function startPlainCalculation() {
  const worker = new Worker('./worker-plain.js');
  writeOutput(' ');
  worker.addEventListener('message', messageEvent => writeOutput(messageEvent.data));
  worker.postMessage(1000);
}

main.js


self.addEventListener('message', messageEvent => {
  const result = fibonacci(messageEvent.data);
  postMessage(result);
});

function fibonacci(num) {
  let a = 1;
  let b = 0;

  while (num >= 0) {
    const temp = a;
    a = a + b;
    b = temp;
    num--;
  }

  return b;
}

worker-plain.js

Next, we implement the same example with Comlink. First, we remove the message listeners and import the Comlink library into the worker and the main application. Notice that you need a build system that can bundle the Comlink library to the main application and the Web Worker. In this example, I'm using Parcel which has built-in build support for Web Workers, like Angular 8.

Next, we need to call Comlink.expose with a reference to the function we want to expose to the main application.

import * as Comlink from 'comlink';

function fibonacci(num) {
  let a = 1;
  let b = 0;

  while (num >= 0) {
    const temp = a;
    a = a + b;
    b = temp;
    num--;
  }

  return b;
}

Comlink.expose(fibonacci);

worker.js

On the other side, in the main application, we instantiate the Worker object like before, but also, we wrap the Worker object with Comlink.wrap. wrap is the counterpart to the expose method. You can only wrap objects that you expose in a worker.

async function startCalculation() {
  const fibonacci = Comlink.wrap(new Worker('./worker.js'));
  writeOutput(' ');
  const result = await fibonacci(1000);
  writeOutput(result);
}

main.js

wrap returns a proxy to the function in the worker, but access to the function is asynchronous. This means that the function that returns a number in the Web Worker now returns a Promise in the main thread. This is an ordinary Promise, and you can either use then, and catch or async/await as I do in this example.

You see that Comlink significantly simplifies the programming model. Under the hood, the main application and the worker still exchange messages with postMessage() and the message event handler, but this is completely hidden from our application. We work with the Web Worker like with any Promise based API.

Like with the plain Web Workers API, all messages you send are by default being copied. Comlink also supports transferring objects by reference. You have to use the Comlink.transfer() method for this purpose.
See the project page for more information about this topic:
https://github.com/GoogleChromeLabs/comlink#comlinktransfervalue-transferables-and-comlinkproxyvalue

Another interesting feature is Comlink.proxy. This allows you to send a proxy of an object to the other side. This is especially useful for callback function because functions can't be transferred.

In the following example, we transfer not only the index of the Fibonacci sequence but also a proxy to a function that updates the DOM.

function startCalculationWithProxy() {
  const fibonacci = Comlink.wrap(new Worker('./worker-proxy.js'));
  fibonacci(1000, Comlink.proxy(writeOutput));
}

const outputElement = document.getElementById('output');

function writeOutput(value) {
  outputElement.innerHTML = value;
}

main.js

In the Worker code, we add a second parameter to the fibonacci method that receives the proxy of the writeOutput function. The function no longer returns any value; instead, it calls the output function and writes the result directly into the DOM.

import * as Comlink from 'comlink';

function fibonacci(num, outputFunction) {
  let a = 1;
  let b = 0;

  outputFunction(' ');

  while (num >= 0) {
    const temp = a;
    a = a + b;
    b = temp;
    num--;
    outputFunction(b);
  }

}

Comlink.expose(fibonacci);

worker-proxy.js

Visit the Comlink project page for more information:
https://github.com/GoogleChromeLabs/comlink

In this example, we combine all the knowledge we learned so far and create an Angular 8 application that draws the Mandelbrot set and utilizes Comlink for exchanging data with the Web Workers.

This is a port of the previous Angular 8 Mandelbrot application. The only difference is that we use Comlink instead of a plain Web Worker.

First I created the Angular application with ng new and then added Web Worker support with ng generate webWorker mandelbrot

In the Web Worker, we implement the algorithm as a regular function that returns a value. Then we import the Comlink library and expose the function with Comlink.expose.

import * as Comlink from 'comlink';

function computeMandelbrotSet({startX, startY, width, height, totalWidth, totalHeight, maxIteration}:
                                {startX: number, startY: number, width: number, height: number,
                                  totalWidth: number, totalHeight: number, maxIteration: number}): number[][] {
  const result = [];
  for (let iy = startY; iy < startY + height; iy++) {
    for (let ix = startX; ix < startX + width; ix++) {
      const cRe = (ix - totalWidth / 2.0) * 4.0 / totalWidth;
      const cIm = (iy - totalHeight / 2.0) * 4.0 / totalWidth;
      let x = 0;
      let y = 0;

      let iteration = 0;
      while (x * x + y * y <= 4 && iteration < maxIteration) {
        const xNew = x * x - y * y + cRe;
        y = 2 * x * y + cIm;
        x = xNew;
        iteration++;
      }
      if (iteration >= maxIteration) {
        result.push([ix, iy]);
      }
    }
  }
  return result;
}

Comlink.expose(computeMandelbrotSet);



mandelbrot.worker.ts

In the main application, we create the Worker and wrap it with Comlink.wrap.

    this.computeMandelbrotSetMethods = [];
    this.workers = [];
    for (let w = 0; w < this.numberOfWorkers; w++) {
      this.workers[w] = new Worker(new URL('./mandelbrot.worker', import.meta.url), {type: 'module'});
      this.computeMandelbrotSetMethods[w] = wrap(this.workers[w]);
    }

app.component.ts

With this in place, we can now call the exposed computeMandelbrotSet function. The method is synchronous in the Web Worker, but when you wrap it with Comlink it changes to an asynchronous method that returns a Promise.

    while (this.workY < this.height) {
      const result = await computeMandelbrotSetMethod({
        startX: this.workX,
        startY: this.workY,
        width: 100,
        height: 100,
        totalWidth: this.width,
        totalHeight: this.height,
        maxIteration: this.maxIteration
      });

app.component.ts

In this last section, we take a look at a more business-like example. The example displays a list of earthquakes that happened in the previous month. In this example, we use the Fetch API to download the list of earthquakes from usgs.gov, parse the CSV file with the Papa Parse library and insert the data into IndexedDB with Dexie.js. We run all these steps in a Web Worker.

The application is based on the Ionic blank starter template. I added the Comlink, Papa Parse and Dexie.js library to the project.

npm install comlink
npm install papaparse
npm install dexie

And created the Web Worker and added the build time support with ng generate webWorker earthquakes-loader

First, we create the Earthquake interface and EarthquakeDb Dexie object. See also my blog post about Dexie.js if you want to know more about IndexedDB with Dexie.js: https://golb.hplar.ch/2018/01/Using-Dexie-js-in-a-TypeScript-application.html

import Dexie from 'dexie';

export class EarthquakeDb extends Dexie {
  earthquakes!: Dexie.Table<Earthquake, string>;

  constructor() {
    super('Earthquake');
    this.version(1).stores({
      earthquakes: 'id,mag,time'
    });
  }
}

export interface Earthquake {
  id: string;
  time: number;
  place: string;
  mag: number;
  depth: number;
  latLng: [number, number];
}


earthquake-db.ts

In the Web Worker, we import the Comlink, and Papa Parse library. We also import the earthquake-db.ts into the Web Worker.

import {Earthquake, EarthquakeDb} from './earthquake-db';
import * as Comlink from 'comlink';

earthquakes-loader.worker.ts

Thanks to Angular's new build time support, we can import anything we want, and the CLI bundles the imported libraries and classes into a separate Web Worker bundle.

The Web Worker code is organized as a TypeScript class with two methods. The code is straightforward and looks the same as if you create a class for the main application. The only difference is that in the end, we expose the class with a call to Comlink.expose.

  id: string;
  time: string;
  place: string;
  mag: string;
  depth: string;
  latitude: string;
  longitude: string
};


class EarthquakesLoader {
  private db: EarthquakeDb;

  constructor() {
    this.db = new EarthquakeDb();
  }

  async load(url: string): Promise<void> {
    const response = await fetch(url);
    const text = await response.text();
    const data = parse<EarthquakeRow>(text, {header: true});

    const earthquakes: Earthquake[] = [];

    for (const row of data.data) {
      if (row.id) {
        earthquakes.push({
          time: new Date(row.time).getTime(),
          place: row.place,
          mag: Number(row.mag),
          depth: Number(row.depth),
          latLng: [Number(row.latitude), Number(row.longitude)],
          id: row.id
        });
      }
    }

    return this.db.transaction('rw', this.db.earthquakes, async () => {
      await this.db.earthquakes.bulkPut(earthquakes);
    });

earthquakes-loader.worker.ts


In the main application, we create a Worker object and then wrap it with Comlink.wrap. We can now instantiate the class, like any other class, with new. The difference to a regular class is that this operation is asynchronous, so new returns a Promise.

      const EarthquakesLoader = Comlink.wrap(new Worker(new URL('./earthquakes-loader.worker', import.meta.url), {type: 'module'}));
      // @ts-ignore
      const earthquakesLoader = await new EarthquakesLoader();

      await earthquakesLoader.load(url);
      console.log('records loaded');
      localStorage.setItem('lastUpdate', Date.now().toString());

      await earthquakesLoader.deleteOldRecords();
      console.log('old records deleted');

      this.changeSubject.next('changed');

earthquake.service.ts

The two methods load and deleteOldRecords are asynchronous as well. They are already asynchronous in the source class. The Comlink proxy does not change that; they are not twice asynchronous or something like that. Notice when you expose/wrap synchronous methods with Comlink that they are always asynchronous and return a Promise afterward.


This concludes the journey around the Web Workers API. It's a handy API for starting background threads. With Angular's 8 new build-time support, it's is very easy to write Web Workers in TypeScript and import 3rd party libraries. And a library like Comlink simplifies the programming model, and you can work with the Web Worker like with any other asynchronous API. All the messaging infrastructure is hidden behind proxies.

The source code for all the examples is hosted on GitHub:
https://github.com/ralscha/blog2019/tree/master/webworkers