Rendering large data with AngularJS 1.4 (ft. Polymer 0.5)

About 6 months ago I worked on a web app in AngularJS that seemed to be very simple but over time it evolved into a quite large project and I was hit by Angular's performance soon and really hard.

I realised how little information is available apart from the most basic recommendations and since I've spent some time optimising our AngularJS app to run well even with 100 000 rendered expressions at once I thought it would be worth sharing it with others.

Everything in this article is related to AngularJS 1.4 and the later part to Polymer 0.5 but I belive it's still relevant because there're so many existing AngularJS apps and Angular 2.0 is yet to come. Even then, most recipes are going to be relevant to new Angular and Polymer as well.

The app I was making is like a smarter Excel table. It's a giant table with thousands of rows and tens of columns. Very important assumption is that the data is most of the time static (by not all the time so I can't use one time bindings) and when we modify it we work with only very little subset (eg. we update just one row).

The most basic idea behind all these tips is to minimise number of watched expressions and to keep the scope tree shallow.

All tips are sorted from the easiest to the most difficult to implement.

1. Avoid nesting ng-repeat directives

This is well know bottleneck. Each item in ng-repeat creates a new scope and each change to any watched variable causes re-rendering the entire subtree:

 1 
 2 
 3 
 4 
 5 
<table>
    <tr ng-repeat="row in rows">
      <td ng-repeat="col in columns">{{ row.columns[col.id] }}</td>
    </tr>
</table>

If I had 100 items and 20 columns this would turn into 2000 expressions to watch. If I added just a single ng-if or ng-href the number would double. This is already too much and absolutely not scalable. Fortunately, it's easy to avoid it by pre-rendering templates:

 1 
 2 
 3 
 4 
 5 
 6 
 7 
<table>
    <tr ng-repeat="row in rows">
        <td>{{ row.columns[1] }}</td>
        <td>{{ row.columns[2] }}</td>
        <td>{{ row.columns[3] }}</td>
    </tr>
</table>

2. Use track by for ng-repeat

Since AngularJS 1.2.* directiveng-repeat supports track by expression to keep track of already existing HTML elements without recreating all of them.

There's been said already enough about track by so I don't think I need to go into it here. Just don't forget to use it.

3. Use ng-show instead of ng-if

The main difference in daily usage between ng-if and ng-show is that the first one creates new scopes and adds/removes HTML elements to the page while the second one just show/hides elements. Using the first one or the seconds one depends mostly on your use case but I think most of the time you can go with ng-show.

Again this makes huge difference when used in loops.

4. Be careful with ng-include

Directive ng-include creates a new scope which is usually not a problem until you use it inside a loop like ng-repeat. In absolutely most cases you don't need new scopes when using ng-include but there's no option to control creating new scopes.

The only way is to write your own include directive that doesn't create scopes.

5. Use one time bindings

AngularJS 1.4 introduced one time bindings. These are great but there's no way to manually refresh what was previously rendered with them. Still, it's useful to rendered variables that don't change at all or the app logic implies that these should be static at all cost.

We'll come back to it later in tip #9: Use one time bindings with custom refresh.

6. Avoid using too many HTML elements

It might seem unlikely but there's not only Angular that can lead to performance issues. With 2000 rows and 30 columns we have 60 000 HTML elements on a page (with 20px height per row and 100px width per column it gives 3000x40000px page size to render). Now imagine that some of the table cells contain also <a> or <span>tags.

In my case each text had to be wrapped inside a <span> tag because I had to be able to tell whether the user clicked inside the text or just the <td> element. Clicking <span> element triggered edition popup while clicking just the <td> outside the text selected the row. This immediately doubles the total number of elements on the page which slows down every so-called "reflow" cycle in the browser (event when the browser has to recalculate position of every element on the page) which makes the app noticeably unresponsive.

 1 
 2 
 3 
 4 
<tr>
    <td><span>Citizen Kane</span></td>
    <td><span>1941</span></td>
</tr>

But we can get rid of all <span> elements with a little hack. We can tell when the user clicks inside the text by calculating the text dimensions by rendering it into a <canvas>. Since we already know x and y coordinates where the user clicked we can distinguish clicks inside/outside the text.

Then on each click get text dimensions, <td>'s position on the page and clicked coordinates and check whether the click was inside the text.

This limits using unnecessary elements enormously although this is very use case specific tip.

7. Avoid using filters

This one is not obvious at first sight. When Angular runs digest cycle it evaluates every expression. Not only variables in expressions. Consider following code:

 1 
<td>{{ item.timestamp|toDate('Y-m-d') }}</td>

In item.timestamp we have a timestamp as an integer that we format using custom toDate() filter. While it seems like the value is processed by the filter only once when it's rendered and it's not called again until we change it and it needs to be rendered again.

In fact that's not very precise. The filter is probably called every time you make any change to any watched variable (the only exception is when AngularJS is processing digest loop only on a small sub-scope and not on the root scope). In my use case I was formatting timestamps just like in the preceding code with moment.js which turned out to slow down Angular's digest loop a lot.

Fixing this is simple. Keep two representations of your data. First "raw" data probably in the same format as they're stored in your database. Second with preformated values to avoid using filteres in expressions. This of course means that when you edit table cells you're modifying the raw data which needs to be again pre-formated so you'll end up with creating angular.$watch() for each data row. That's not as bad as it seems since every rendered table row already creates at least one angular.$watch() per column so this doesn't make much difference.

We'll come back to it right in the next tip.

8. Have a look at Immutable.js

Immutable.js is a library developed by Facebook that let's you create arrays or objects that are immutable. In other words if you want to modify them you have to create their copies. This can be well utilised with angular.$watch() to avoid using objectEquality = true (read more about using objectEquality ) option which would be necessary to catch all changes to underlying arrays from tip #7: Avoid using filters.

With Immutable.js we can just use angular.$watch() to compare reference quality and therefore even when each row of our data contains tens or hundreds of items we know that if any of them change then the row needs to be replaced with a new Immutable.js object. This lets us stay with angular.$watch() with objectEquality = false.

9. Use one time bindings with custom refresh

This seems weird. We know that we can't use one time bindings because the data isn't static and there's no way in AngularJS to refresh one time bindings.

However, we can make a little hack. We can take the HTML template with one time bindings, interpolate it with values, bind watchers, copy local variables (from the old scope to the new one) and replace the old HTML element with the new one. I'm not sure this is the "right way" but I can confirm it works very well.

In order to refresh the HTML template we listen to an event on the <tbody> element.

Then in practise, use like in the following template:

 1 
 2 
 3 
 4 
 5 
<tr data-id="{{ ::item.id }}" one-time-refresh>
    <td>{{ ::item.title }}</td>
    <td>{{ ::item.year_released }}</td>
    <td>{{ ::item.added_by }}</td>
</tr>

This reduces number of watched expressions tremendously. Some of them still have to be created like those from ng-show, ng-href (these are copied to the new HTML template in copyLocalVariables()) and so on but these could be also replaced by custom ones that would be evaluated in the event handler after calling copyLocalVariables().

I saw this idea with taking a template and interpolating it with variables on someone's blog and I extended it with copying local variables and triggering re-rendering with events but I can't find it anymore so I can give credit to the original author.

10. Switch to Polymer's <core-list> (<iron-list> in Polymer 1.0)

The last tip is the most difficult one because it probably requires you to completely rewrite your HTML templates. While I have doubts about practical usefulness of most of Polymer elements, <core-list> (or <iron-list> in Polymer 1.0) is the one that stands out and is really great. I used Polymer 0.5 because at the time I was making the app it was the only stable version.

Connecting AngularJS 1.4.* with Polymer 0.5 is easy using ng-polymer-elements which works well with probably all Polymer elements except <core-list>. This extension compares variables with angular.equals() and creates deep copies of all variables using angular.copy() when passed from Angular to Polymer and vice versa which makes it absolutely useless when using <core-list> and large data.

Therefore I made a forked version of ng-polymer-elements which hasn't been merged to the main repository yet (although it's been more than 3 months since I send my pull request). The main difference is that you can use two new attributes angularEquals and onlyAngularToPolymer.

The first one controls whether ng-polymer-elements uses angular.equals() to deeply compare objects or just their references. The second one controls whether you want to set another watcher (it's called observer in Polymer) in case you modified the variable inside the Polymer element.

We don't want to copy variables from Angular to Polymer and we don't even want to watch variables by Polymer because we can modify data always only inside Angular. In contrast to most of Polymer elements, <core-list> doesn't wrap its content inside Shadow DOM so we can bind events to it with $.live().

Note that we can't use ANY Angular directives inside any Polymer element. Everything inside <core-list> has to come from Polymer.

Another very important issue is that <core-list> is not allowed inside <table> elements. This means you can't use <core-list> like:

 1 
 2 
 3 
 4 
 5 
 6 
 7 
 8 
 9 
 10 
 11 
 12 
 13 
 14 
<table>
    <thead>...</thead>
    <tbody>
        <core-list ng-model="myData" angularEquals="false" onlyAngularToPolymer="true">
            <template>
                <tr>
                   <td></td>
                   <td></td>
                   <td></td>
                </tr>
            </template>
    </core-list>
    </tbody>
</table>

Unfortunatelly, probably the only way to make tables with <core-list> is to rewrite your HTML template completely and use only <div>. For me this was quite a letdown because it's not mentioned in Polymer's documentation and it took me about 2 days to actually make everything work again. The final HTML structure looks like this:

 1 
 2 
 3 
 4 
 5 
 6 
 7 
 8 
 9 
 10 
 11 
 12 
 13 
 14 
<div class="table">
    <core-list ng-model="myData" angularEquals="false" onlyAngularToPolymer="true">
        <template>
            <div class="row">
                <div>
                   <template if="{{ model.url }}">
                       <a href="{{ model.url }}">{{ model.title }}</a>
                   </template>
                </div>
                <div></div>
           </div>
       <template>
    </core-list>
</div>

Note that I have to use Polymer's if followed by <template> directive instead of Angular's ng-if.

Conclusion

I utilized all the tips collected here. At the end I replaced tip #9: Use one time bindings with custom refresh with tip #10: Switch to Polymer's ( in Polymer 1.0) and I need to say it works very well. Using angular.$watch() to compare only object references works surprisingly fast even on iPad with a few thousands of watch expressions.

There're more projects aiming to reduce watched expressions like abourget-angular but I haven't tried them myself because these wouldn't help me much anyway.

Even with all this you don't need to care about Angular's performance 99% of time because it's fast enough. For the last 1% I recommend you to use <core-list> from the very beginning because it saves a lot of time in the future.

blog comments powered by Disqus