AngularJS Performance Tuning for Long Lists

AnglarJS is great! But when dealing with large lists containing complex data structure, things can get very slow! We ran into that problem when migrating our core admin screens to AngularJS. The screens were supposed to work smoothly when displaying some 500 rows. But the first approach took up to 7 seconds to rende. Terrible!

We discovered two main performance issues for our implementation. One is related to the ng-repeat directive, the other was related to the filtering.

The following article summarizes our experiences with different approaches to solve or mitigate the performance problem. It will give you ideas and hints, what you can try out yourself and what is maybe not be worth a try.

Why is ng-repeat in AngularJS slow with large lists?

The ng-repeat directive of AngularJS is getting slow above 2500 two-way data bindings. You can read more about this in a post by Misko Hevery (See further reading no 2). This is due to AngularJS watching for changes by “dirty checking”. Every watch consumes time, so large lists with complex data structure will slow down your application.

Useful prerequisites for analyzing performance

Time logging directive:

To measure the time a list rendering takes we wrote a simple directive, which logs the time by using the ng-repeat property “$last”. The reference date is stored in our custom TimeTracker-service, so the logging is independent of data loading from the server.

   
// Post repeat directive for logging the rendering time
angular.module('siApp.services').directive('postRepeatDirective', 
  ['$timeout', '$log',  'TimeTracker', 
  function($timeout, $log, TimeTracker) {
    return function(scope, element, attrs) {
      if (scope.$last){
         $timeout(function(){
             var timeFinishedLoadingList = TimeTracker.reviewListLoaded();
             var ref = new Date(timeFinishedLoadingList);
             var end = new Date();
             $log.debug("## DOM rendering list took: " + (end - ref) + " ms");
         });
       }
    };
  }
]);

// Use in HTML:
<tr ng-repeat="item in items" post-repeat-directive>…</tr>
Timeline feature of Chrome developer tools

In the timeline tab of Chromes developer tools, you can see events, the browsers frames per second and memory allocation. The memory tool is useful to detect memory leaks and to see how much memory your page needs. Page flickering is mostly a problem when the frame rate is below 30 frames per seconds. The frames tool provides insight into the rendering performance. Additionally it displays how much CPU time a JavaScript task consumes.

Basic tuning by limiting size of list

The best way to mitigate the problem is by limiting the size of the displayed list. You can do that by pagination, or by infinite scrolling.

Pagination

Our way to paginate is to combine the AngularJS ‘limitTo’ filter (since version 1.1.4) and a custom ‘startFrom’ filter. This setting allows us to reduce rendering time by limiting the size of the displayed list. It is the most effective way to reduce rendering time.

    
// Pagination in controller
$scope.currentPage = 0; 
$scope.pageSize = 75;
$scope.setCurrentPage = function(currentPage) {
    $scope.currentPage = currentPage;
}

$scope.getNumberAsArray = function (num) {
    return new Array(num);
};

$scope.numberOfPages = function() {
    return Math.ceil($scope.displayedItemsList.length/ $scope.pageSize);
};

// Start from filter
angular.module('app').filter('startFrom', function() {
    return function(input, start) {         
        return input.slice(start);
};

// Use in HTML
// Pagination buttons
<button ng-repeat="i in getNumberAsArray(numberOfPages()) track by $index" ng-click="setCurrentPage($index)">{{$index + 1}}</button

// Displayed list
<tr ng-repeat="item in displayedItemsList | startFrom: currentPage * pageSize  | limitTo:pageSize" /tr>

If you can’t or don’t want to use pagination, but you have problems with slow filtering, be sure to check out step 5 and use ng-show to hide excluded elements.

Infinite scrolling

For our use case infinite scrolling was not an option. If you want to dive deeper into that, the following link leads to an AngularJS infinite scrolling project:
http://binarymuse.github.io/ngInfiniteScroll/

Tuning guidelines

1. Render the list without data binding

This is the most obvious solution, since data-binding is most likely the source of the performance woes.  Ditching the data binding is absolutely fine if you just want to display the list once, and there is no need to deal with updates or changes. Unfortunately you lose control of the data, so this approach was no option for us. Further reading: https://github.com/Pasvaz/bindonce

2. Do not use a inline method call for calculating the data

In order to filter the list directly in the controller, do not use a method for getting the filtered collection. Ng-repeat evaluates the expression on every [$digest(http://docs.angularjs.org/api/ng.$rootScope.Scope#$digest)%5D, which is done very often. In our example the “filteredItems()” returns the filtered collection. If this evaluation is slow, it will quickly slow down the whole application.

    
<li ng-repeat="item in filteredItems()"> // Bad idea, since very often evaluated.
<li ng-repeat="item in items"> // Way to go! 

3. Use two lists (one for the view to display, one as data source)

A useful pattern is to separate the displayed list and the data list. This allows you to preprocess some filters and apply the cached collections to the view. The following example shows a very basic implementation. The filteredLists variable is holding the cached collections, the applyFilter method handles the mapping.


/* Controller */
// Basic list 
var items = [{name:"John", active:true }, {name:"Adam"}, {name:"Chris"}, {name:"Heather"}]; 

// Init displayedList
$scope.displayedItems = items;

// Filter Cache
var filteredLists['active'] = $filter('filter)(items, {"active" : true});

// Apply the filter
$scope.applyFilter = function(type) {
    if (filteredLists.hasOwnProperty(type){ // Check if filter is cached
        $scope.displayedItems = filteredLists[type];
    } else { 
        /* Non cached filtering */
    }
}

// Reset filter
$scope.resetFilter = function() {
    $scope.displayedItems = items;
}

/* View */
<button ng-click="applyFilter('active')">Select active</button>
<ul><li ng-repeat="item in displayedItems">{{item.name}}<li></ul>

4. Use ng-if instead of ng-show for additional templates

If you use directives or templates to render additional information, for instance to display details of a list item on click, be sure to use ng-if(since v. 1.1.5). Ng-if  prohibits rendering (in contrast to ng-show). Therefore the additional DOM elements and data-bindings are evaluated on demand.

    
<li ng-repeat="item in items">
    <p> {{ item.title }} </p>
    <button ng-click="item.showDetails = !item.showDetails">Show details</buttons>
    <div ng-if="item.showDetails">
        {{item.details}}
    </div>
</li>

5. Do not use AngularJS directives ng-mouseenter, ng-mouseleave, etc.

Using the built-in directives like ng-mouseenter AngularJS caused our view to flicker. The browser frame rate was mostly below 30 frames per second. Using pure jQuery to create animations and hover-effects solved this problem. Be sure to wrap the mouse events in jQuery’s .live() function – to be aware of later DOM changes.

6. Tuning hint for filtering: Hide elements with ng-show that are excluded

With long lists, the use of filters will also work slower, due to each filter createing a sub collection of the original list. In many cases, when the data does not change, filter results stay the same. Therefor a pre-filtering of the data list and accordingly applying it to the view saves processing time.
By applying filters in the ng-repeat directive, each filter returns a subset of the original collection. AngularJS is removing the excluded elements from the DOM and, by calling $destroy, also removes them from the $scope. When the filter input changes, the subset changes and elements have to be relinked or $destroyed again.
In most cases this is absolutely fine, but in case the user filters often, or the list is very large, this continuous linking and destroying impacts performance. To speed up the filtering, you can use ng-show and ng-hide directives. Calculate the filters in the controller and add a property to each item. Trigger ng-show depending on that property. As a result, this will only add the class ng-hide to the elements instead of removing them from sub-list, $scope and DOM.

  • One way to trigger ng-show is to use the expression syntax. The ng-show value is evaluated by using the build in filter syntax.
    See also the following plunkr example

     
    <input ng-model="query"></input>
    <li ng-repeat="item in items" ng-show="([item.name] | filter:query).length">{{item.name}}</li>
    
  • Another way to do that is to use pass in an attribute for the ng-show and do the computation for that value in a separate sub-controller. This way is a bit more complex, yet cleaner way like Ben Nadel suggests in his blog post

7. Tuning hint for filtering: Debounce input

Another way to solve the continuous filtering described at point 6, is to debounce user input. For instance, if a user types a search string, the filter only needs to be activated after the user stops typing. A good solution is to use this debounce service http://jsfiddle.net/Warspawn/6K7Kd/. Apply it in your view and controller as follows:

    
/* Controller */
// Watch the queryInput and debounce the filtering by 350 ms.
$scope.$watch('queryInput', function(newValue, oldValue) {
    if (newValue === oldValue) { return; }
    $debounce(applyQuery, 350);
});
var applyQuery = function() { 
    $scope.filter.query = $scope.query;
};

/* View */
<input ng-model="queryInput"/>
<li ng-repeat= item in items | filter:filter.query>{{ item.title }} </li>

Further reading

Got feedback or ideas?

We’d love to hear your thoughts! It took us some time to figure out these hints. We were not able to find any comprehensive guidelines on this matter.  Leave a comment below if you have further suggestions, or happen to know other performance-related guidelines. We’d love to mention them, or to link to your post if you have one.

21 comments

  1. I totally agree with debouncing a watched property’s update. One who would require a periodical list’s update during typing could also implement throttling.

    1. Hi Jordan, thank you for your comment. I just added both methods, setCurrentPage() is a simple $scope method for setting the actual page. getNumber() was kind of confusing, I agree! I renamed it to getNumberAsArray(). This returns you the number of pages as array, so you can loop over them with ng-repeat. I hope this clears things up, if not feel free to ask again!

  2. Thank you for sharing your experience of ng-tunings. Already met this beast especially while developing for mobile devices.
    The one more thing I can add is to take care about filters usage (ng.$filter, not ng.filter, you covered that already). The approach with them is the same as you already do – calculate data inside controller and introduce result into property instead of wiring it up on the fly with filter.

  3. Thanks for this great article. I am on a similar adventure right now in trying to increase performance on an Angular app that could be 500+ rows of data in a table. I’m interested to know in applying these techniques what is your rendering time now and with how many rows are you showing after the performance optimizations? Do you have any standard rule of thumbs you came up with for max number of rows to show per page? (i.e. Desktop should only show 200 rows per page? Mobile should only show 100 rows per page?)

    1. Hi, thank you! It really depends on the bindings per screen. A rule of thumb is hard to define, it depends on the rendering time you want to achieve. Like Misko Hevery says in his post 2000 bindings per screen could be a rule of thumb. In your example each row is supposed to have not more than 2000/500 = 4 bindings, which isn’t a lot. Use a paginated and pre filtered list as source for ng-repeat. Your optimisation should be based on requirements. Let’s say you want a rendering time of 400ms. This means each watch expression should be executable within 0.1 ms = 400ms / (2000*2) based on 2000 bindings. Each $digest() triggers “at least” two dirty checks, that’s why we multiply with 2 here. You can find expensive watch statements with batarang. We’re working on another article with more focus on how to measure, wich tools help identify problems and try to provide examples where we suggest solutions. See a WIP state here

  4. Recently built a site that loads thousands of database entries for online editing. It was really bogged down on searches and orderby. I implemented an infinite scroll directive and dropped my 4000+ entry option to about a two second load time (took about 8 seconds before). Searches and orderby are instant, and they search the entire list, not just the rendered ones. Seems like the easiest fix to me.

    1. Hi there Seth
      Thanks for the reply, infinite scrolling definitely is another solution.
      Do you have a good generic implementation, i would like to add a link in the post.
      Cheers

  5. In my implementation of your code I found that startFrom needed to be used as follows:

    startFrom: (currentPage – 1) * itemsPerPage

    This ensures an appropriate index is calculated (starting at 0).

    1. Hi Joel,
      Thanks for the note.
      Since the currentPage is the $index of the pages array, it starts at 0. Maybe you have passed the “user friendly page number”, starting at 1?
      ng-click=”setCurrentPage($index)”

  6. Thanks for the write up!

    One question – is it possible to search outside of your set limitTo? In my case, I have a huge data set that the main use would be filtering through a search. I need to increase performance and I’ve implemented several techniques written here and it is no match to 17k entries… which I know is a lot for any case.

    Would it be possible to show a limit of say 100 but still be able to search beyond those 100’s?

    1. Hi Pedro,
      Thanks for your comment!
      Of course you can search through all items. What matters is the order of limitTo and filter.
      If you limit the array first and then filter – you only search in the limited scope.
      If you filter first and limit then – you search the whole array.

      I’ve created a plunk four you to play with it:
      http://embed.plnkr.co/k4Dm6KP6rdENUBlWd06d/

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s