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)
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)
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.
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.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
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
You have to explicitly call subscription.unsubscribe() in order for the workers to be torn down. In other words, this:
Really needs to be this:
Just went through this helpful introduction. As of 2022, there seem to be just a few differences to highlight:
ObservableWorker
has been deprecated.runWorker
is intended to replace it, like this:fromWorker
. It will terminate those workers when the subscription is unsubscribed (not just when the observable emits Complete). So this code:Should really be this code:
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
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.
Looks really nice and interesting. Im definitely going to give it a look.
Well done !!!
Thanks for sharing!!!
Excellent post. Can you please include, either at the top or bottom (or both), a link to the follow-up article?
Thanks!
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 :)