Improving the Performance of Complex Angular Applications

The application is under continuous development for two years now and recently we have experienced some serious performance issues for the first time.
Old Typewriter

At Babbel our learning content is maintained and created using a custom-made content authoring system based on Angular 1.x. The application has become quite complex by now, counting about 80 custom directives, 35 services and 10 filters, which are tested by about 1400 unit tests (just so you get an idea of the sheer size). The application is under continuous development for two years now and recently we have experienced some serious performance issues for the first time. Our most complex view consists of a spreadsheet-like layout containing, on average, about 50-80 of rows filled with content. When users switched between content packages, resulting in the view being updated, there was a noticeable lag of 2-3 seconds, which became annoying quickly.

This article will walk you through our process and explain in detail how we cut the rendering time in half.


Recognizing the Problem is the First Step to Recovery

We used several tools to drill down to the root cause of the issue. Unfortunately, using the profiler of Chrome is not very helpful. When we looked at a CPU profile of our application we saw that $digest and $apply took up most of the execution time, but not which functions, called during the digest cycle, took the majority of the time.

Screenshot of CPU profile

ng-stats

In order to get more details on why Angular is spending so much time inside the digest loop we used a small script called ng-stats. It can be run as a snippet inside Chrome Dev Tools. Make sure to run Angular in debug mode, otherwise the script will not work. You can do this by either calling Angular.reloadWithDebugInfo() from the console or configuring your main module like this:

.config(['$compileProvider', function($compileProvider) {
  $compileProvider.debugInfoEnabled(true);
}])

If you reload your Angular application, run the snippet and call the function showAngularStats() to get the statistics. It will add a small visualization in the upper left corner. Inside, you will see the total number of expressions being watched by Angular right now (number on the left ) and the average duration of the last digest cycle in milliseconds (number on the right). In order to identify the views that slow down your application you can click through your application and observe how the stats change. As a rule of thumb, there should be no more than 2000-3000 watchers in total, but preferably we should aim for significantly fewer. The average digest cycle should take no more than a 100ms, if this time is exceeded most users will no longer perceive the computation as instantaneous (source).

Determining Your Point of Attack

Now that you have identified the areas of your application which need improvements it is time to dig deeper into the problem. This script will allow you to count the watchers for the directives which you suspect of slowing your application down. Once again you need to run Angular in debug mode. Afterwards, paste the code into a snippet in Google Chrome and run it using cmd/ctrl + Enter. The console will print an object containing the total amount of watchers as well as a list of the expressions being watched. At first, this might be a little overwhelming. I recommend using Chrome’s inspect tool to select a single directive inside the DOM and to run getWatchers($0); from the console ($0 represents the selected DOM element). This will provide you with a more digestible result and you will know exactly which module you will need to look at in order to reduce your number of watchers. If you are not sure where to start, try looking at directives that are located inside an ng-repeat, because reducing the footprint of these directives will have the biggest impact.

List of Angular Watchers

Culprits

Watchers

Angular sets up watchers to enable its magical two-way data binding. Whenever you use an expression inside your Angular templates using the double curly braces like this “ a watcher is set up that will update the template in case the value changes inside your Javascript models. Furthermore, Angular directives like ng-if and ng-class rely on watchers to provide their dynamic behavior. Especially problematic is ng-repeat, because it sets up a watcher for every single element as well as the collection. Each watcher is dirty-checked during every digest cycle, which can occur as often as several times per second (usually after an user interaction).

Scope Functions and Filters

Calling functions directly from your templates can be quite performance heavy. It basically means they are executed on every digest cycle, even if the user interaction that triggered the digest cycle is not related to your binding in any way. Angular does not know that the result might be the same, so it has to check every time.

<div>{{ computeValue() }}</div>

The same is true for filters defined in your templates. Since they are also functions called during each digest cycle (or even several times during each cycle), even if the value did not change, they have the same detrimental effect to your app performance as the scope functions mentioned above.

<div>{{ value | uppercase }}</div>

How to Fix It

Bind Once

Since Angular 1.3 values can be bound once, which means that after they have been rendered, they will not be updated anymore and therefore do not have to be watched by Angular. The Angular Documentation defines it like this:

An expression that starts with :: is considered a one-time expression. One-time expressions will stop recalculating once they are stable, which happens after the first digest if the expression result is a non-undefined value.

Basically, every read-only data point that will not change during the lifetime of the directive should be bound once. Examples include dropdown lists or navigation bars.

<div>{{ ::value }}</div>

The same syntax can also be used to reduce the footprint of ng-repeat.

<div ng-repeat="user in ::userCollection">
  {{::user.name}}
</div>

Pre-Compute Scope Variables and Filters

Whenever possible try to pre-compute your values and assign the already computed value to the scope. The variable will still be checked by Angular during the digest cycle, but at least the function used for the computation is not called every single time.

<div>{{ computedValue }}</div>

You can do the same for filters. The $filter provider allows us to reach the same effect by applying it inside our controllers. It will only be called once, or if you set it up inside a $watch, when the value actually changes, and not on every digest cycle like the equivalent inside a template.

$scope.uppercaseValue = $filter('uppercase')($scope.value);

Utilize ngModelOptions

The ngModelOptions directive was introduced in Angular 1.3 and allows us to specify exactly when our model will be updated (and thus when the next digest cycle occurs). Using this feature makes sense when dealing with forms and input fields, because inputs trigger a digest cycle every time the value changes, which means that every keystroke of the user results in a (potentially heavy) computation of all our watched expressions. The example below highlights the use of the debounce option, which specifies, that the model is only updated after 300ms have passed. The timer is restarted if another change occurs within the 300ms.

<input type="text"
       name="userName"
       ng-model="user.name"
       ng-model-options="{ debounce: 300 }">

Another feature of ngModelOptions is updateOn, which defines specifically, which user interaction triggers a model update. For input fields it makes sense to update the model after a blur event occurred, that is, after the field lost focus and the user has quite likely settled on an input value. In theory every DOM event which might occur from an input field can be used though.

<input type="text"
       name="userName"
       ng-model="user.name"
       ng-model-options="{ updateOn: 'blur' }">

For a full list of options available with ngModelOptions consult the Angular Documentation.

ng-if Instead of ng-show

This little trick did wonders for us. The difference between ng-if and ng-show is that ng-if removes elements from the DOM, whereas ng-show only hides them. This means if you are using ng-show to hide a complex component, containing many expressions being watched, all these watchers will still be active. This is despite the fact that users do not benefit from expressions being updated which are invisible to them. As a rule of thumb we try to use ng-if wherever possible and as long as we do not expect the state of the element to change more than once during the lifetime of a directive. Dropdown lists might be a good example where you actually want to use ng-show, because inserting and removing the whole dropdown menu each time it is used will get expensive quickly.

Plain Old CSS

In one of our views we were using ng-if to display a placeholder in case the image was missing. In order to get rid of this directive (and an unnecessary watcher), we used CSS to absolutely position the image on top of the placeholder when it is present. If no image exists, the placeholder is visible. This way we achieved the same behavior without Angular ever having to watch and compute an additional expression.

Pagination and Infinite Scroll

The solutions mentioned above are all technical, as a last resort you might want to consider a non-technical approach that involves changing your application’s user interface. If repeating over complex directives or very long lists slows your application down, maybe it is time to speed it up by reducing the number of elements being looped over. The most straight-forward way of doing this is to paginate your results and only display e.g. 30 elements at once. On request, the user gets the next 30 elements.

If you do not want to compromise your app’s user experience, a smoother solution might be to use infinite scroll. As the user scrolls, new elements are being loaded and appended to the view. There is a ready-made Angular directive called ngInfiniteScroll that allows you to do that.

Conclusion

Unfortunately, Angular was not built for performance out of the box, but over time the Angular team added several features that allow developers to significantly speed up their applications. There is no easy way of achieving faster rendering times, so you have to try different techniques and see what works best for you. At Babbel we were able to cut the rendering time in half by reducing our watchers by about 50%, utilizing several of the techniques mentioned above. We still have a long way to go, our most complex view still has about 4000 watchers and the initial digest cycle takes about 1200ms, which results in a noticeable lag.

One final word of advise: Make sure you don’t compromise too much on code quality just to get a tiny performance boost. At some point we started re-implementing components in pure html that were formerly encapsulated in a custom directive. This resulted in bloated templates that were much harder to read and maintain, while at the same time only improving the performance slightly. Try to find a trade-off between code quality and performance that makes you and your users happy. Happy performance boosting!

Photo by Markus Winkler on Unsplash

Want to join our Engineering team?
Apply today!
Share: