DEV Community

Cover image for Observable Web Workers with Angular (8) - Introduction
Zak Henry
Zak Henry

Posted on • Updated on

Observable Web Workers with Angular (8) - Introduction

tl;dr

Web Workers are awesome and Angular CLI now supports them natively.

Unfortunately, the Web Worker API is not Angular-like and therefore, I'm introducing a library observable-webworker

If you already know what web workers are and how to use them in Angular, feel free to skip ahead where I will show you how to refactor the initial worker generated by the CLI into a superpowered worker with observable-webworker.

Context

In this article series we will explore using Web Workers to offload heavy computation to another thread. Keeping the main thread free for interaction with the user is important. We don't typically think of threading very often in the frontend world, as non-blocking APIs and the event loop model of Javascript typically allows us to have execution of user interaction events in-between longer running processes such as waiting for an HTTP request to resolve.

Because of this, for the most part, you don't need threading or web workers for most tasks. However, there are a set of problems that do require heavy computation that would ordinarily block the main thread, and therefore user interaction (or modification of the DOM). This issue can manifest itself in stuttering animations, unresponsive inputs, buttons that appear not to work immediately etc.

Often the answer to this has been to run intensive computations server side, then send the result back to the browser once done. This solution does have a real cost however - you need to manage a server API, monetarily computation itself isn't free (you're paying for the server) and there may be a significant latency issue if you need to interact with the computation frequently.

Fortunately, Web Workers are the solution to all this - they allow you to schedule units of work in browser that run in parallel to the main execution context, then once done can pass their result back to the main thread for rendering in the DOM etc.

Creating your first Web Worker in Angular

The worker API is relatively simple - in Typescript you can create a new worker with just

const myWorker = new Worker('./worker.js');

This is all very well, however we love to use Typescript, and be able to import other files etc. Being stuck with a single javascript file is not very scalable.

Previously working with workers (heh) in Angular was fairly painful as bundling them requires custom Webpack configuration. However, Angular CLI version 8 brings built-in support for properly compiling & bundling web workers.

The docs are at https://angular.io/guide/web-worker but we will step through everything required (and more!) here.

Before we get going, we've set up an empty Angular CLI project with

ng new observable-workers

Next we need to enable web worker support, so we run

ng generate web-worker

We're prompted for a name, so we will call it demo.

Angular CLI will now update tsconfig.app.json and angular.json to enable web worker support, and create us a new demo.worker.ts:

/// <reference lib="webworker" />

addEventListener('message', ({ data }) => {
  const response = `worker response to ${data}`;
  postMessage(response);
});

To get this worker running, lets update our AppComponent:

import { Component } from '@angular/core';

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.scss']
})
export class AppComponent {

  runWorker() {

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

    demoWorker.onmessage = (message) => {
      console.log(`Got message`, message.data);
    };

    demoWorker.postMessage('hello');

  }

}

And the template:

<button (click)="runWorker()">Run Worker</button>

Okay, so now we're all good to go, run the application if you aren't already, and you'll be greeted with a single button in the DOM. Are you excited yet?

Click that sucker, and you will see in the dev console

Got message worker response to hello

Yeehaw, we got a response from a separate thread, and Angular compiled the worker for us.

Also, to prove this is actually a worker, if you go to the Sources tab in Chrome, you will see that there is a worker running there as 1.worker.js.

The fact that we can still see it running is important - it means that we have not destroyed the worker, despite receiving the only message we will get back from it. If we click the button again, we will construct a brand new worker, and the old one will continue to hang around. This is a bad idea! We should always clean up after we're done with a worker.

Before we worry about how to destroy a worker, let's reflect for a bit on the API that we have with workers so far - we need to:

  • construct the worker
  • declare a property and assign a function to it in order to get the response back from the worker
  • addEventListener within the worker itself, we have to
  • call a global postMessage function to send back information to the main thread

This API doesn't feel very Angular-like does it? We're used to deal with clean hook-based APIs and have fallen in love with RxJS for dealing with streams of data and asynchronous responses, why can't we have this for Web Workers too?

Well, we can. And this happens to be the whole point of this article. I'd like to introduce a new library observable-webworker which seeks to address this clunky API and give us a familiar experience we're used to with Angular.

I should note that this library doesn't actually depend on Angular at all, and will work beautifully with React or any other framework or lack thereof! The only dependency is a peerDependency on RxJS.

To best introduce the concepts of the library, we will refactor our current web worker to use observable-webworker.

Implementing observable-webworker

To start, we will install observable-webworker. I'm using Yarn, but you know how to install packages right?!

 yarn add -E observable-webworker

First of all, we'll update the AppComponent
<!-- embedme src/readme/observable-webworker-implementation/app.component.ts -->

import { Component } from '@angular/core';
import { fromWorker } from 'observable-webworker';
import { Observable, of } from 'rxjs';

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.scss']
})
export class AppComponent {

  runWorker() {

    const input$: Observable<string> = of('hello');

    fromWorker<string, string>(() => new Worker('./demo.worker', { type: 'module'}), input$)
      .subscribe(message => {
        console.log(`Got message`, message);
      });

  }

}

Note we've imported fromWorker from observable-webworker. The first argument is a worker factory - we need to have the ability to lazily construct a worker on-demand, so we pass a factory and the library can construct it when needed. Also Webpack needs to find the new Worker('./path/to/file.worker') in the code in order to be able to bundle it for us.
The second argument input$ is a simple stream of messages that will go to the worker. The generics (<string, string>) that we pass to the worker indicate the input and output types. In our case the input is a very simple of('hello').

Now for the worker:

import { DoWork, ObservableWorker } from 'observable-webworker';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';

@ObservableWorker()
export class DemoWorker implements DoWork<string, string> {

  public work(input$: Observable<string>): Observable<string> {

    return input$.pipe(
      map(data => `worker response to ${data}`)
    );
  }

}

You can see we have completely restructured the worker to use an API that is far more familiar to Angular developers - we use a decorator @ObservableWorker() which lets the library bootstrap and work its magic, and we implement the DoWork interface, which requires us to create a public work(input$: Observable<string>): Observable<string>; method.

The actual content of the method is super simple. We process the observable stream with a simple map, which is returned from the method. Behind the scenes the library will call postMessage to get the data back to the main thread.

You can now run the application again and it will work exactly as before, with the one exception that the worker will automatically be terminated as no more work needs to be done.

As we're now using observables, the library is able to know when subscribers have finished listening and can clean up the worker appropriately.

This wraps up Part One of this series. In later articles we will discuss a more real-world implementation of workers, how to deal with errors, and how to work with very large object passing between main and worker threads. Possibly I will also do a deep dive into the implementation of observable-webworker as it uses some of the more exotic RxJS operators like materialize() and dematerialize().

All of these features are available with observable-webworker now, so I encourage you to check out the readme, as it goes into more detail about the features than I do here in this article.


This is my first blog post ever! I'd love to hear from you if you have any feedback at all, good or bad!

Top comments (13)

Collapse
 
johncarroll profile image
John Carroll • Edited

FYI: you link to the NPM package several places in the article, but the package.json is apparently missing a link to the associated git repo.

I can't speak for everyone obviously, but I have no interest in npmjs.org links. I only use them to find the associated git repo. For anyone else looking for the git repo, you can find it here: github.com/cloudnc/observable-webw... (the last link in the article references it).

(also, thanks for open-sourcing your work! Looks super useful)

Collapse
 
zakhenry profile image
Zak Henry

Thanks for the tip John, I have actually already fixed this however it hasn't been released due to the semantic release program computing the change was immaterial and didn't warrant a version bump, so this fix will go out with the next addition to the library. I'll see if there is anything obvious I can fix up to ensure it gets out as this is kinda annoying I agree.

Collapse
 
johncarroll profile image
John Carroll • Edited

Dunno what your pipeline is, but I remember running into something like this with lerna and I was able to make use of an option (--force ? --force-publish ?) to force out an otherwise identical patch release.

Collapse
 
bboyle profile image
Ben Boyle

This sounds great Zak, thanks for sharing!

I'm confused though. I think I followed these steps right, but the worker threads aren't being terminated. Did I miss something? github.com/bboyle/observable-workers

Collapse
 
bboyle profile image
Ben Boyle

seems to be the version of observable-workers. I see the worker threads being terminated if I use version 3.0.1. doesn't seem to be an issue with the advanced blog posts

Collapse
 
jzabinskidolios profile image
jzabinski-dolios • Edited

You have to explicitly call subscription.unsubscribe() in order for the workers to be torn down. In other words, this:

  runWorker() {
    const input$: Observable<string> = of('hello');

   fromWorker<string, string>(
      () => new Worker(new URL('./app.worker', import.meta.url), { type: 'module' }),
      input$
    ).subscribe((message) => {
      console.log(`Got message`, message);
    });
Enter fullscreen mode Exit fullscreen mode

Really needs to be this:

subscription = new Subscription();

  runWorker() {
    const input$: Observable<string> = of('hello');

    this.subscription.add(fromWorker<string, string>(
      () => new Worker(new URL('./app.worker', import.meta.url), { type: 'module' }),
      input$
    ).subscribe((message) => {
      console.log(`Got message`, message);
      ==> this.subscription.unsubscribe(); <==
    })
    );
Enter fullscreen mode Exit fullscreen mode
Collapse
 
jzabinskidolios profile image
jzabinski-dolios

Just went through this helpful introduction. As of 2022, there seem to be just a few differences to highlight:

  1. ObservableWorker has been deprecated. runWorker is intended to replace it, like this:
import { DoWork, runWorker } from 'observable-webworker';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';

export class DemoWorker implements DoWork<string, string> {
  public work(input$: Observable<string>): Observable<string> {
    return input$.pipe(map((data) => `worker response to ${data}`));
  }
}

runWorker(DemoWorker);
Enter fullscreen mode Exit fullscreen mode
  1. If you run this code in a browser, you will probably notice that the worker thread continues to stick around after it has run. This is because the workers are kept as part of the inner workings of fromWorker. It will terminate those workers when the subscription is unsubscribed (not just when the observable emits Complete). So this code:
  runWorker() {
    const input$: Observable<string> = of('hello');

   fromWorker<string, string>(
      () => new Worker(new URL('./app.worker', import.meta.url), { type: 'module' }),
      input$
    ).subscribe((message) => {
      console.log(`Got message`, message);
    });
  }
Enter fullscreen mode Exit fullscreen mode

Should really be this code:

--> subscription = new Subscription();

  runWorker() {
    const input$: Observable<string> = of('hello');

-->     this.subscription.add(fromWorker<string, string>(
      () => new Worker(new URL('./app.worker', import.meta.url), { type: 'module' }),
      input$
    ).subscribe((message) => {
      console.log(`Got message`, message);
-->      this.subscription.unsubscribe();
    })
    );
  }
Enter fullscreen mode Exit fullscreen mode
Collapse
 
andrewa40539253 profile image
Andrew Allen

I would love to know more about the inner workings. Btw you and maxime1992 always seem to ask the same question I research. Thank you for all the public info you output

Collapse
 
shivenigma profile image
Vignesh M

Man, You can't possibly know how happy I am to see this library. Going to try it over the weekend and on next week possibly in a project where I am currently performance optimizing.

Collapse
 
johnasbroda profile image
Ferenczfi Jonatán

Looks really nice and interesting. Im definitely going to give it a look.

Collapse
 
achimoraites profile image
Achilles Moraites

Well done !!!
Thanks for sharing!!!

Collapse
 
narshe1412 profile image
Manuel

Excellent post. Can you please include, either at the top or bottom (or both), a link to the follow-up article?
Thanks!

Collapse
 
zakhenry profile image
Zak Henry

Hey Manual, thanks for the support! DEV now automagically adds this to the beginning of all articles that are marked as a part of a series :)