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:
- Implement a micro angular feature set with just scopes, watchers and digest cycle.
- Use the micro angular implementation to compute lots of prime numbers to slow down the digest cycle.
- I will render the results to the DOM and profile the page's performance (it will be slow!)
- The entire application can be moved to the web worker, but this completely changes the application's nature.
- 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
- Bonus 1: I will restore the original plain JavaScript syntax used to update scopes using
Object.observe
- 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
1 | function Scope() { |
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.
1 | Scope.prototype.$digest = function() { |
We can use the above code like this
1 | <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.
1 | var scope = new Scope(); |
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.
1 | Scope.prototype.$digest = function(cb) { |
We can now render the generated primes
1 | <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 | console.profile('finding primes'); |
Because the client code and the browser repainting happen in the same code the following steps actually happen in the above code
- digest cycle code runs
- function
afterDigest
kicks off - we set
innerHTML
property- browser schedules DOM update, layout, rendering, painting to be run after current event queue is empty.
- we schedule
stopProfile
function.stopProfile
goes to the end of the event queue, behind DOM update, layout, etc.
- function
afterDigest
finishes - DOM is updated, layout is recomputed, it is rendered and painted
- function
stopProfile
runs, stopping profiling
When computing first 5000 primes, we get the following CPU usage
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.
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.
1 | importScripts('micro-angular.js', 'primes.js'); |
The main page can use this code to compute primes without freezing like this
1 | var worker = new Worker('worker.js'); |
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
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
)
1 | <script src="mock-scopes.js"></script> |
Our mock-scopes.js
provides this Angular-like API to the client code, but it only sends messages
to the web worker.
1 | (function initMockScopes(root) { |
Finally, our micro-angular-worker.js
receives messages in the web worker and actually
updates the scopes.
1 | importScripts('micro-angular.js', 'primes.js'); |
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!
1 | var scope = new Scope(); |
Here is mock-scopes.js
implementation of scope.$watch
method
1 | Scope.prototype.$watch = function (watchFn, listenerFn) { |
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
.
1 | case '$watch': |
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:
1 | var scope = new Scope(); |
Instead, you should only use variables attached to the scope inside the watch and listener functions.
1 | var scope = new Scope(); |
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.
1 | function render(str) { |
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.
1 | // we will render html received from the worker, if there is a hook |
Here is the worker $digest
implementation.
1 | case '$digest': |
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.
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).
1 | function render(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.
1 | var scope = new Scope(); |
Our mock scopes code will just observe each created mock scope instance, and will send messages on any changes to the web worker.
1 | function Scope() { |
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.
1 | Scope.prototype.$digest = function ($compile) { |
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.
1 | var diff = require('virtual-dom/diff'); |
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.