Run Angular digest cycle in web worker

An experiment in offloading AngularJs dirty checking and model updates to a separate browser thread.

Right now I am excited about two things:

When thinking about browser performance, I always go back to the basic fact:

Client code and the browser user events / layout / rendering / painting all happen in turns in a single thread. When your code runs for too long, user's interaction is blocked.

When your code takes too long the user cannot even scroll!

Angular is my framework of choice right now, and its slowest part is often the digest cycle. During the digest cycle, the angular engine looks at every object attached to its scopes to see if anything has changed since the last cycle. If anything has changed, things happen: model data is updated with new values, which in turn updates the DOM, etc. Under the hood, every scope has watchers - functions that return values that the engine can check against previous ones. This is called dirty checking and can be quite slow if there are lots of objects to compare. It can also be slow if listener functions that run on data changes take long to finish.

In this experiment, I will improve the page responsiveness by offloading the digest cycle to the separate thread via web worker. I plan to show how to achieve this in several steps:

  1. Implement a micro angular feature set with just scopes, watchers and digest cycle.
  2. Use the micro angular implementation to compute lots of prime numbers to slow down the digest cycle.
  3. I will render the results to the DOM and profile the page's performance (it will be slow!)
  4. The entire application can be moved to the web worker, but this completely changes the application's nature.
  5. I will step back and will show how to move only the micro angular code to the web worker
    • I will move scopes, watcher functions and digest cycle, leaving Angular-like application in the main context.
    • Same application that finds primes will run without freezing the browser
  6. Bonus 1: I will restore the original plain JavaScript syntax used to update scopes using Object.observe
  7. Bonus 2: I will propose using virtual DOM to speed up browser updates.

Each step will be described in enough detail to follow, but you can always grab the example repo and try it for yourself.

Digest cycle in a few lines of code

Let us create a micro AngularJs library that can keep track of scopes and implements dirty checking. Then we will speed things up by moving the digest cycle and dirty checking into separate thread.

I created a sample git repo bahmutov/digest-cycle-in-web-worker that you can clone and run locally. Different steps are tagged step-1, step-2, etc.

git clone [email protected]:bahmutov/primes.git
cd primes
git checkout step-1
open index.html

We can make scopes and keep track of the watchers ourselves in just a few lines of code

micro-angular.js
1
2
3
4
5
6
7
8
9
10
function Scope() {
this.$$watchers = [];
}
Scope.prototype.$watch = function(watchFn, listenerFn) {
var watcher = {
watchFn: watchFn,
listenerFn: listenerFn || function() { }
};
this.$$watchers.push(watcher);
};

We can implement the digest cycle ourselves in a few lines of code. I grabbed code for a single digest cycle from the excellent Make Your Own AngularJs.

micro-angular.js
1
2
3
4
5
6
7
8
9
10
11
Scope.prototype.$digest = function() {
var self = this;
this.$$watchers.forEach(function (watch) {
var newValue = watch.watchFn(self);
var oldValue = watch.last;
if (newValue !== oldValue) {
watch.listenerFn(newValue, oldValue, self);
watch.last = newValue;
}
});
};

We can use the above code like this

index.html
1
2
3
4
5
6
7
8
9
10
11
12
13
14
<script>
var scope = new Scope();
scope.$watch(function watcherFn(scope) {
return scope.foo;
}, function listenerFn(newValue, oldValue, scope) {
if (newValue)
console.log('new foo value', newValue);
});
scope.$digest();
// nothing happens
scope.foo = 42;
scope.$digest();
// 'new foo value 42'
</script>

You can find this code at tag step-1

Long digest cycle

Let us introduce code to significantly slow down the digest cycle. We can slow down the watcher function or the listener function. If we slow down the watcher function, we will slow down EVERY digest cycle, even if nothing has changed. This is often the reasoning for using fewer and simpler watchers in your application. If we slow down the listener function instead, then it will only affect the application if something has changed.

I will slow down the listener function by finding first N primes if the user set scope.n. The implementation is slow on purpose, and finding even a couple of thousand primes takes seconds.

scope watch example
1
2
3
4
5
6
7
8
9
10
11
12
13
var scope = new Scope();
scope.$watch(function watcherFn(scope) {
return scope.n;
}, function listenerFn(newValue, oldValue, scope) {
if (newValue) {
console.log('finding', newValue, 'primes');
console.timeline('finding primes');
scope.primes = findPrimes(newValue);
console.timelineEnd('finding primes');
}
});
scope.n = 5000;
scope.$digest();

When I load the page, it is initially frozen, while the listener function runs. The output is

finding 5000 primes index.html:24
finding primes: 8599.160ms

The digest cycle froze the browser for almost 9 seconds! You can try the code for yourself at step-2

Rendering results

Let us add render the found primes. For now, we can just form HTML markup and stick it into DOM after the digest cycle finishes. We can separate rendering by passing a callback into the $digest function that will only be called if the model is dirty.

micro-angular.js
1
2
3
4
5
6
7
8
9
10
11
12
13
Scope.prototype.$digest = function(cb) {
var self = this;
var dirty = this.$$watchers.some(function (watch) {
var newValue = watch.watchFn(self);
var oldValue = watch.last;
if (newValue !== oldValue) {
watch.listenerFn(newValue, oldValue, self);
watch.last = newValue;
return true;
}
});
dirty && cb && cb(this);
};

We can now render the generated primes

index.html
1
2
3
4
5
6
7
8
9
10
11
12
<script>
scope.n = 1500;
scope.$digest(function afterDigest(scope) {
var n = scope.n;
var str = '<ul>';
for (k = 0; k < n; k += 1) {
str += '<li>' + (k + 1) + ' prime ' + scope.primes[k] + '</li>';
}
str += '</ul>';
document.getElementById('primes').innerHTML = str;
});
</script>

Profiling digest cycle and DOM update

To better understand the performance of our code, let us profile the digest cycle and DOM update using Chrome DevTools CPU profiler. We will start the profile before the digest cycle and will finish the profile after the browser updates the DOM nodes and repaints. We will achieve this by setting a timeout call after setting the HTML markup.

1
2
3
4
5
6
7
8
console.profile('finding primes');
scope.$digest(function afterDigest(scope) {
// form markup
document.getElementById('primes').innerHTML = str;
setTimeout(function stopProfile() {
console.profileEnd('finding primes');
}, 0);
});

Because the client code and the browser repainting happen in the same code the following steps actually happen in the above code

  1. digest cycle code runs
  2. function afterDigest kicks off
  3. we set innerHTML property
    1. browser schedules DOM update, layout, rendering, painting to be run after current event queue is empty.
  4. we schedule stopProfile function.
    1. stopProfile goes to the end of the event queue, behind DOM update, layout, etc.
  5. function afterDigest finishes
  6. DOM is updated, layout is recomputed, it is rendered and painted
  7. function stopProfile runs, stopping profiling

When computing first 5000 primes, we get the following CPU usage

digest-then-dom

The tiny gray rectangle on the right shows DOM layout + rendering + painting execution step, which takes 72 ms. We can see this better by increasing length of markup string appended to the DOM

document.getElementById('primes').innerHTML = str + str + str + str + str;

Even better, let us collect the timeline events instead of profiling just the JavaScript code.

digest then dom timeline

You can see in the red rectangle around the browser layout, rendering and painting steps. The code is at tag step-3.

Move ALL code to web worker

Let us kick off another browser thread and move our micro angular implementation (and prime computation) from the main browser thread to the web worker. The main thread and the web worker can communicate via messages.

worker.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
importScripts('micro-angular.js', 'primes.js');
onmessage = function (e) {
console.log('worker received message:', e.data);
switch (e.data.cmd) {
case 'primes':
var scope = new Scope();
scope.$watch(function watcherFn(scope) {
return scope.n;
}, function listenerFn(newValue, oldValue, scope) {
if (newValue) {
console.log('finding', newValue, 'primes');
scope.primes = findPrimes(newValue);
}
});
scope.n = e.data.n;
scope.$digest(function afterDigest(scope) {
var n = scope.n;
var str = '<ul>';
for (k = 0; k < n; k += 1) {
str += '<li>' + (k + 1) + ' prime ' + scope.primes[k] + '</li>';
}
str += '</ul>';
postMessage({
html: str
});
});
break;
}
};

The main page can use this code to compute primes without freezing like this

1
2
3
4
5
6
7
8
9
var worker = new Worker('worker.js');
worker.onmessage = function (e) {
var str = e.data.html;
document.getElementById('primes').innerHTML = str;
};
worker.postMessage({
cmd: 'primes',
n: 5000
});

You can see the code at step-4 but you will need simple http server to serve the page and the worker javascript. I usually use http-server.

Moving scopes to the web worker

The browser behavior is much nicer when the computation is offloaded to the web worker. The user can interact with the page while the code runs. This also mimics the desktop GUI libraries where the drawing / events thread is separate from the application's thread.

Yet, I look at the code and cannot recognize it. It is no longer an AngularJs application, my micro angular engine is only running in the worker, and the client code has no idea what is going on. I would like to write an Angular application and only run the digest cycle in the worker.

If we move the digest cycle into the web worker, then we have to store the data model there too, otherwise shipping the data back and forth is too expensive. Here is the conceptual separation between the main page and the worker:

    main context (index.html)    |       worker
---------------------------------|-------------------------------------------
   loads mock-angular.js         |    loads micro-angular.js and primes.js
   client uses mockScopes        |          actual scopes

All our operations on the scope's data then become messages from the main script to the worker. For example, creating a scope and setting scope.foo from the client becomes the following sequence

set foo

I used js-sequence-diagrams to render the above picture.

Let us look at the code. First the application code is back to Angular-like (except for using .set)

index.html
1
2
3
4
5
<script src="mock-scopes.js"></script>
<script>
var scope = new Scope();
scope.set('foo', 42);
</script>

Our mock-scopes.js provides this Angular-like API to the client code, but it only sends messages to the web worker.

mock-scopes.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
(function initMockScopes(root) {
var digestWorker = new Worker('./micro-angular-worker.js');
var scopes = 0;
function Scope() {
this.id = '$' + scopes;
scopes += 1;
digestWorker.postMessage({
cmd: 'Scope',
id: this.id
});
console.log('created mock scope', this.id);
}
Scope.prototype.set = function (name, value) {
digestWorker.postMessage({
cmd: 'set',
id: this.id,
name: name,
value: value
});
console.log('set mock scope', this.id, 'property', name, '=', value);
};
root.Scope = Scope;
}(this));

Finally, our micro-angular-worker.js receives messages in the web worker and actually updates the scopes.

micro-angular-worker.js
1
2
3
4
5
6
7
8
9
10
11
12
13
importScripts('micro-angular.js', 'primes.js');
var scopes = {};
onmessage = function digestOnMessage(e) {
console.log('micro-angular-worker received:', e.data);
switch (e.data.cmd) {
case 'Scope':
scopes[e.data.id] = new Scope(e.data.id);
break;
case 'set':
scopes[e.data.id][e.data.name] = e.data.value;
break;
}
};

When we run this code, it produces the following output

created mock scope $0 mock-scopes.js:13
set mock scope $0 property foo = 42 mock-scopes.js:23
micro-angular-worker received: Object {cmd: "Scope", id: "$0"} micro-angular-worker.js:6
micro-angular-worker received: Object {cmd: "set", id: "$0", name: "foo", value: 42} 
// web worker has correct Scope instance { foo: 42 }

You can find this code at step-5 tag.

Moving watchers to the web worker

Our digest cycle depends on the user-defined watcher and listener functions. How do we add these functions from the client's context to the scopes that reside in the web worker? By serializing and shipping the function's code of course!

index.html
1
2
3
4
5
6
7
var scope = new Scope();
scope.set('foo', 42);
scope.$watch(function (scope) {
return scope.foo;
}, function (newVal, oldVal, scope) {
console.log('new scope.foo', newVal);
});

Here is mock-scopes.js implementation of scope.$watch method

mock-scopes.js
1
2
3
4
5
6
7
8
Scope.prototype.$watch = function (watchFn, listenerFn) {
digestWorker.postMessage({
cmd: '$watch',
id: this.id,
watchFn: watchFn.toString(),
listenerFn: listenerFn && listenerFn.toString()
});
};

The key point is that we serialize the function's code using .toString method and ship it across the web worker context boundary, where we recreate it using eval.

micro-angular-worker.js
1
2
3
4
5
6
7
8
case '$watch':
// e.data.watchFn = watchFn.toString()
// e.data.listenerFn = listenerFn.toString()
scopes[e.data.id].$watch(
eval('(' + e.data.watchFn + ')'),
e.data.listenerFn && eval('(' + e.data.listenerFn + ')')
);
break;

We restored and registered both watcher and listener functions, but since they no longer used from where they are declared, we cannot access variables that are normally accessible through the lexical scope. For example:

index.html
1
2
3
4
5
6
var scope = new Scope();
var bar = 'need this value';
scope.$watch(function watchFn(scope) {
return bar;
// ReferenceError: watchFn will execute in web worker, not here!
});

Instead, you should only use variables attached to the scope inside the watch and listener functions.

index.html
1
2
3
4
5
6
var scope = new Scope();
scope.set('bar', 'need this value');
scope.$watch(function watchFn(scope) {
return scope.bar;
// works fine because scope = { bar: 'need this value' } is passed to the function
});

You can try this code at tag step-6

Move digest cycle to the web worker

Finally, let us move the digest cycle to the worker code. We also need to send the updated HTML markup string from the web worker back to the client code after the dirty digest cycle.

index.html
1
2
3
4
5
6
7
function render(str) {
document.getElementById('foo').innerHTML = str;
}
// set foo and watcher
scope.$digest(function compile(scope) {
return '<b>scope.foo = ' + scope.foo + '</b>';
});

Here is the client-side $digest mock. Again we are shipping a function to be called from main context to the web worker using toString method.

mock-scopes.js
1
2
3
4
5
6
7
8
9
10
11
12
// we will render html received from the worker, if there is a hook
var digestWorker = new Worker('./micro-angular-worker.js');
digestWorker.onmessage = function (e) {
root.render && root.render(e.data.html);
};
Scope.prototype.$digest = function ($compile) {
digestWorker.postMessage({
cmd: '$digest',
id: this.id,
$compile: $compile && $compile.toString()
});
};

Here is the worker $digest implementation.

micro-angular-worker.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
case '$digest':
scopes[e.data.id].$digest(function digestFinished() {
var $compile, scope, html;
if (e.data.$compile) {
$compile = eval('(' + e.data.$compile + ')');
scope = scopes[e.data.id];
html = $compile(scope);
}
postMessage({
cmd: 'digestFinished',
html: html
});
});
break;

We are running micro-angular $digest cycle, and if there were any changes to the model (based on watchers), listeners will run, and then digestFinished evaluates the $compile function and posts a message that goes back to the client context, including the produced HTML string.

full sequnce

The page shows scope.foo = 42 in bold font after the code completes. You can find this code at step-7

Finding primes example

Let us go back to our initial example: finding first 5000 primes. Now we can code the application using the same Angular-like approach (except for scope.set('n', 5000); nonsense).

index.html
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
function render(str) {
document.getElementById('primes').innerHTML = str;
}
var scope = new Scope();
scope.set('n', 5000);
scope.$watch(function (scope) {
return scope.n;
}, function (newVal, oldVal, scope) {
console.log('finding first', newVal, 'primes');
scope.primes = findPrimes(scope.n);
});
scope.$digest(function compile(scope) {
var n = scope.n;
var str = '<ul>';
for (k = 0; k < n; k += 1) {
str += '<li>' + (k + 1) + ' prime ' + scope.primes[k] + '</li>';
}
str += '</ul>';
return str;
});

We can now capture the page's timeline, and try scrolling while the primes are being generated inside the digest cycle. Everything works. You can try the code at step-8.

Bonus - use Object.observe to restore plain Angular syntax

The fact that the client code has to use scope.set('n', 5000); and not plain JavaScript scope.n = 5000; upsets me. So let us make it work by using Object.observe

  • a feature already available in the Chrome browser.
index.html
1
2
var scope = new Scope();
scope.n = 5;

Our mock scopes code will just observe each created mock scope instance, and will send messages on any changes to the web worker.

mock-scopes.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function Scope() {
this.id = '$' + scopes;
scopes += 1;
digestWorker.postMessage({
cmd: 'Scope',
id: this.id
});
var self = this;
Object.observe(this, function changed(changes) {
changes.forEach(function (change) {
switch (change.type) {
case 'add':
case 'update':
self.set(change.name, change.object[change.name]);
break;
}
});
});
}

There is slight problem: the messages to the web worker arrive in the wrong order

var scope = new Scope();
scope.n = 5000;
scope.$digest();
// web worker messages
micro-angular-worker received: Object {cmd: "Scope", id: "$0"}
micro-angular-worker received: Object {cmd: "$digest", ...}
micro-angular-worker received: Object {cmd: "set", id: "$0", name: "n", value: 5}

Hmm, why do we start the digest cycle before setting n property? Our Object.observe callback runs asynchronously by scheduling the changed callback onto the event loop. To fix the message order we need to change $digest to schedule the postMessage via event loop too.

mock-scopes.js
1
2
3
4
5
6
7
8
9
10
Scope.prototype.$digest = function ($compile) {
var self = this;
setTimeout(function () {
digestWorker.postMessage({
cmd: '$digest',
id: self.id,
$compile: $compile && $compile.toString()
});
}, 0);
};

Problem solved!

You can try the code at step-9 in the Chrome browser.

Bonus - optimize DOM update

When generating new HTML markup, we are just replacing the current innerHTML contents with new string. This is very inefficient, if the generated markup only differs a little from the current DOM. A better approach would be to generate a virtual dom tree in the web worker and ship only the difference back to the main context via postMessage. Then the DOM can be efficiently updated a patch function.

web worker
1
2
3
4
5
6
7
8
9
10
11
12
13
14
var diff = require('virtual-dom/diff');
var prevTree = initial virtual tree;
$digest(function afterDigest() {
var newTree = $compile(scope);
var patches = diff(prevTree, newTree);
postMessage(patches); // we can even release patches!
prevTree = newTree;
});
// index.html
var patch = require('virtual-dom/patch');
var rootNode = document.getElementById('primes');
function render(patches) {
rootNode = patch(rootNode, patches);
}

I tried using just a diff and patch library diff-renderer, but it is crushing for any non-trivial markup (like a list with a few items).

Update 1 For ng-conf I created simpler repo with just a few demo steps, see repo2. The slides presented at the conference are at bahmutov/run-digest-cycle-in-web-worker.