Last Updated: August 02, 2018
·
56.34K
· lperrin

Speeding up AngularJS's $digest loop

Angular's magical HTML/JS binding depends on a very efficient dirty-checking algorithm. However, when you eventually reach its limit, your app is doomed to be sluggish. When all else fails, you can just cheat.

First, a primer on AngularJS dirty checking

When you add an ng-click handler, Angular will call your function and patiently wait until you return. Then, it has to guess all the changes you made to your scopes. Scopes are normal JS objects: nothing special happens when you modify them and Angular has no easy way of tracking your changes.

Whenever you use an {{model.value}} binding, AngularJS turns it into a function (using the $parse module) and adds it to a private list: scope.$$watchers (scope.$watch does the same). To discover changes, Angular has no choice but to call all the $$watchers to see if their results has changed compared to previous calls. There is often at least 1 watcher per HTML element and a regular app will probably use thousands of them.

Anyway, just know that whenever anything happens in your app, Angular will call all your $$watchers: hundreds if not thousands of JS functions (most of them generated on the fly). As your app grows, this will take longer and longer, and might eventually result is noticeable, embarassing freezes.

Surprisingly, this is usually not a problem, even if the doc advises you against showing more than ~2000 elements at once. Note that AngularJS 2.0 will probably bring greatly increased performance for that.

I am unfortunate enough to write most of my code on an 11' MacBook Air that's starting to show its age and I've ran in this limit quite often.

The long list problem

Suppose you have a long list made of says, several thousands cells. In order to stay below the 2000 elements limit, you might be tempted to add a handler on the scroll event, determine which cells are are visibles, and hide the others. Unfortunately, this will make your app crawl badly (~5fps). The reason is that the scroll event fires very often: possibly at every available frame. If your digest loop completes in, say, 100ms, it will be acceptable for responding to a click, but well above the 16ms required for 60fps.

There are some obvious optimisations, like debouncing the event, but it will have a limited impact. I've found that the more you debounce (the longer you wait before 2 successive digests), the more visible cells you need (so the user won't have time to scroll beyond visible cells). Overall, you have to find a balance between a low fps (short debounce), freezes (long debounce) or a glitchy app.

You could also try limiting the number of $$watchers with angular-once, but it will effectively disable AngularJS and you might as well stick to jQuery.

My trick: selectively disabling $$watchers

Assuming we have this markup:

<ul ng-controller="listCtrl">
  <li ng-repeat="item in visibleList">{{lots of bindings}}</li>
</ul>

And this code:

app.controller('listCtrl', function ($scope, $element) {
  $element.on('scroll', function (e) {
    $scope.visibleList = getVisibleElements(e);
    $scope.$digest();
  });
});

During the $digest, you are only interested in changes to visibleList, not changes to individual items. Yet, Angular will still interrogate every single watcher for changes.

So, I wrote this very simple directive:

app.directive('faSuspendable', function () {
  return {
    link: function (scope) {
      // Heads up: this might break is suspend/resume called out of order
      // or if watchers are added while suspended
      var watchers;

      scope.$on('suspend', function () {
        watchers = scope.$$watchers;
        scope.$$watchers = [];
      });

      scope.$on('resume', function () {
        if (watchers)
          scope.$$watchers = watchers;

        // discard our copy of the watchers
        watchers = void 0;
      });
    }
  };
});

And changed my code to:

<ul ng-controller="listCtrl">
  <li fa-suspendable ng-repeat="item in visibleList">{{lots of bindings}}</li>
</ul>

app.controller('listCtrl', function ($scope, $element) {
  $element.on('scroll', function (e) {
    $scope.visibleList = getVisibleElements(e);

    $scope.$broadcast('suspend');
    $scope.$digest();
    $scope.$broadcast('resume');
  });
});

What is does is simply temporarily mask the watchers of individual items. Rather than go through hundreds of watchers, Angular will just check if elements were added or removed from my visibleList. The app instantly went back to 60fps when scrolling!

The great thing is that all other events are still working are usual. You can have your cake AND eat it:

  • Monitor closely scroll events to hide all invisible elements and greatly reduce the number of watchers.
  • Have manageable $digest cycles for all other events.

22 Responses
Add your response

I love it :)

It will be even better if you can detect when $digest loop is called and when it finishes, to remove those $scope.$broadcast(), but as far as I know it is not possible in AngularJS.

Another improvement could be to use $rootScope.$emit() that is far better in performance than $scope.$broadcast() due to the bubbling propagation, but anyway is pretty smart and I will use this very soon I think.

over 1 year ago ·

If you use $rootScope.$emit, you (obviously) need to use $rootScope.$on in your scopes. Problem is, you scopes will not automatically unregister when they are destroyed and you must do it manually:

var unwatch = $rootScope.$on('bla', …);
scope.$on('$destroy', unwatch);

I've seen my app leak like mad because of that, so I avoid that now unless I have to, but you're right: it would be faster :)

over 1 year ago ·

Thank you for the nice article.

As for using $rootScope.$on issue with $destroy > here is a trick that I found on ng site that I use >
</br>
$provide.decorator('$rootScope', ['$delegate', function($delegate) { </br> $delegate.constructor.prototype.$onRootScope = function(name, listener){ </br> var unsubscribe = $delegate.$on(name, listener); </br> this.$on('$destroy', unsubscribe); </br> }; </br> return $delegate; </br> }]);</code> </br>

This takes care of the zombie listeners on $destroy. I don't have the direct link to this issue, handy at the moment, but you should be able to find it on Angular's github site.

Thanks once again for the excellent article. Will surely refer back to this when I hit the perf wall in future. :-)

over 1 year ago ·

Hadn't thought about that. Thanks for sharing!

over 1 year ago ·

Awesome stuff, thanks for the tip! Instead of:

watchers = undefined;

you should do

watchers = void 0;

This is because undefined is simply a variable on the window object that hasn't been defined yet while void 0 will always return undefined.

over 1 year ago ·

Don't use $scope.$digest directly, instead, use $scope.$apply

$element.on('scroll', function (e) { $scope.$apply(function(){
    $scope.visibleList = getVisibleElements(e);
 }); });
over 1 year ago ·

$apply will check all the watchers of the app, while $digest will only check the current scope and its children. In my case, I'm only interested in detecting changes in the list and I need to go as fast as I can because the scroll event fires very often.

over 1 year ago ·

Nice post! one question: I'ts possible that you show me the implementation of method: getVisibleElements(e) ?

Thanks!!

over 1 year ago ·
over 1 year ago ·

Hi, clever approach:) I wonder if it would be possible to "mark" somehow the {{value}} bindings that may be disabled, and remove them from the $$watchers array selectively. Is it possible to add a new "special" directive to angular, like [[value]], for example?

One thing I didn't understand is what do you mean by "hiding" the invisible elements. Wouldn't this affect the scroll area, even disable the scroll bar altogether, if only the visible elements take space in the container?

Thank you!

over 1 year ago ·

That's exactly what I do, I have a "fa-suspendable" directive that activates the code (see in the example).

To prevent the scroll bar from disappearing, I adjust the top and bottom padding of the container so that it replaces the space where the missing cells are.

over 1 year ago ·

Right, the padding should do the job (why didn't I think of that:)). How fa-suspendable works is clear to me, I was just wondering if it could be extended to suspend (possibly for good) just some of the watchers in its scope, not all, for example those following a syntax like this: {{value+''}} which shouldn't affect the rendered value, but could help identifying them. However, I can't find a way to get the original expression between the curly braces when looking at the watcher (its fields eq, exp, fn, get, last do not contain this info, and watchStr variable is "private"). And it would get too hacky anyway.

So I think I'll just try to group the suspendable fields in an fa-suspendable element, and keep the others out of it or in a different child scope.

over 1 year ago ·

What if...

.directive('faSuspendable', function () {
return {
link: function (scope) {
// Heads up: this might break is suspend/resume called out of order
// or if watchers are added while suspended
var watchers = [];
scope.$on('suspend', function () {
for (var i = 0, l = scope.$$watchers.length; i < l; i += 1){
watchers.push(scope.$$watchers[i]);
}
scope.$$watchers = [];
});

  scope.$on('resume', function () {
    while (watchers.length > 0) {
        scope.$$watchers.unshift(watchers.pop());
    }
  });
}

};
})

over 1 year ago ·

Yeah, but the use-case is really to do something like suspend / digest / resume during a scroll event. You are not expected to add new watchers.

over 1 year ago ·

If you remove all watchers with the line scope.$$watchers = []; isn't it useless to call the $digest function then? Because $digest check every watcher (which there aren't any of). I didn't get that part :S

over 1 year ago ·

@eolognt: I am only removing removing watchers from scopes that have the fa-suspendable directive.

Typically, in a very long list, you'd use this to suspend the watchers of individual cells, while keeping those on the list itself. It would allow you to quickly render new cells as they become visible and hook a $digest cycle on the scroll event.

over 1 year ago ·

@lperrin
this would only work if you place it on a directive that has an isolate scope I suppose? (otherwise you have no clue which watchers you are disabling ?)

over 1 year ago ·

@DevBaptist, disabling all watchers is not a big deal because you are only disabling them for a single $digest cycle.

The idea is to make some $digest cycles super fast, for example if you are in a scroll event and you are only interested in making new cells visibles. Watchers will still be processed by other digests so your app will keep rendering correctly.

over 1 year ago ·

Hi Iperrin,
can we have example it would be help full for me.. where you are using this directive..

over 1 year ago ·

Thanks all for supporting me and using my directive however I'm no that much good developer I make you guys fool but still you idiots like my post thanks from bottom of my heart :P

over 1 year ago ·
over 1 year ago ·

Would this directive work to suspend watchers while the component is hidden by ng-show

directive("suspendWatchers", function() {
return {
restrict: 'A',
link: function (scope, element, attrs) {
scope.$watch(attrs.ngShow, function(newval) {
toggleWatchers(scope, false, newval, this);

            function toggleWatchers(scope, sibling, pause, keep) {
              if (pause) {
               if (scope.watchers_bckp) {
               scope.$$watchers = scope.watchers_bckp; 
               scope.watchers_bckp= [];
                }   
              } else {
                scope.watchers_bckp = scope.$$watchers;
                scope.$$watchers = []; 
                scope.$$watchers.push(keep);
             }

             if (scope.$$childHead)    
                toggleWatchers(scope.$$childHead, true, pause); 

              if (scope.$$nextSibling && sibling)
                toggleWatchers(scope.$$nextSibling, true, pause);           
           }
      })
    }
  }
})
over 1 year ago ·