Angular Performance Considerations or: How We Ended Up Detaching $$watchers ¬
When I came to Curalate, our dashboard was a bunch of page-specific jQuery mixed with some helpful libraries. Naturally, this setup became unwieldy as we added more developers to the team and more features to our product. This model became unmaintainable as we planned on more cohesive and complex features and we decided to begin using Angular. The change was welcomed throughout the team and has changed our development process for the better, but migrating to Angular did not come without issues. In this post, I’ll describe the performance bottlenecks we encountered when creating one particularly complex feature using Angular and the methods we employed to solve these issues.
The problem
After converting our first page to Angular and building our knowledge and confidence a bit, we began working on a new feature. The new page included a large grid of images, each of which had associated actions. We noticed sluggish performance when interacting with images in the grid and some galleries even caused the browser to hang completely.
The source of the slowness is a slight irony. The mechanisms which make Angular such a powerful framework, namely two-way binding and dirty-checking, were also the root of the issue. Our new feature required us to support many hundreds of images in a gallery, each outfitted with buttons which, when clicked, affected the state of that model as well as the collection as a whole.
- We had an infinite-scrolling image gallery with potentially thousands of images
- Users could alter the state of the gallery by interacting with buttons on each image. They could also filter the gallery.
- Watcher count increased as more images were loaded into the gallery. If enough images were loaded, the page would crash
The low-hanging fruit
The first flaw we found is one that is well-documented on other blogs: we weren’t using track by
along with ng-repeat
to avoid churning of DOM elements. Under the covers, Angular assigns a value to each element in a collection called $$hashKey
[1]. Within ng-repeat
, Angular uses this key to determine whether it needs to update the DOM. This can cause element churn when altering the collection. We used track by
to mitigate this issue by using an ID from the model. Some other blogs cover this in more detail [2].
After adding track by
to the ng-repeat
we saw a minor performance increase, but nowhere close to the easy win we were hoping for. As newcomers to Angular, we were just beginning to understand what was going on under the hood. After profiling the performance of the page using Chrome’s Timeline tool, we observed that an inordinate amount of page load time was spent in executing Javascript. The alarming aspect was the sheer number of functions executing and not the execution duration of each function. After a bit more digging, we attributed the function calls to watchers called from Angular’s $digest loop and began profiling the number of watchers on the page.
One way we reduced the number of watchers was with Angular’s one-time binding expression syntax [3]. One-time binding prevents recalculation of bound values after the given value has stabilized (and is not equal to undefined
). We converted a handful of bind expressions to use this since many expressions will not change over the lifetime of the page, such as each image’s source url. However, converting all of the expressions we could to one-time binding didn’t quell all of our performance issues. This is where the real fun began.
Where we’re going, we don’t need roads
We wanted the page to be fast. This meant having results from user interaction take no more than 100ms to appear [4]. Many watchers couldn’t be moved over to use one-time binding syntax because their values actually needed to be updated after the user takes action on an image.
It was clear that the basic solutions, while helpful, were not enough to support this page. The key observation here is that although we theoretically load an infinite amount of images into the gallery, only a constant number are in the viewport at a given time. Thus, we should be able to limit the number of watchers on the page to be constant as well, since updates to models outside of the viewport have no immediate effect to the user.
Our idea was to create a directive which:
- Operated mainly outside of the Angular-context (because we couldn’t afford adding more watchers)
- Detected when scopes entered and exited the viewport
- Added and removed watchers from scopes based on this information, effectively “suspending” a scope
To support requirements 1 and 2, we used plain old jQuery. Using functions like .offset()
, .height()
, and .scrollTop()
, we were able to discern whether an element’s top-left was within the viewport. Supporting requirement 3 was a bit trickier and required digging through some Angular internals. Angular watchers are stored on a given scope in a property called $$watchers
. The directive simply keeps a map of generated IDs to $$watchers
. When suspending a scope, the $$watchers
array reference is moved from the scope to the directive’s internal map and $$watchers
is set to an empty array. The opposite occurs when a scope’s element comes back into view.
The logic described above is thrown into a loop which traverses the scopes within a specified element. This loop is executed on a periodic timer, or any time the window is scrolled or. As the user scrolls the page, the stale scopes will be updated.
Integrating this directive on the new page changed everything. We were able to keep the watcher count constant on the page, allowing us to load in an incredible amount of content without overloading the browser. The page flew, going from a few thousand watchers maximum to a few hundred at any given time.
An example
An explanation is only worth so much. To better demonstrate the power of suspendable
, we created an Angular performance playground. The playground has a contrived situation which hits on the watcher count pain point using a simple UI. The example loads with suspendable
disabled. The header reports the number of watchers on the page currently as well as the duration of the last $digest
loop. Try adding a few thousand rows and altering the contents of the inputs.
Once you’ve grown tired of the awful performance, try out the version which uses suspendable
. Add a few thousand rows and scroll. With debug enabled, a border is added to illustrate the state of a scope. A green border means the scope is “active” and its watchers are attached, while a red border means the scope is “suspended”. Scroll and watch as suspendable
does its job: resuming scopes and keeping the watcher count constant as you scroll.
The improvement is even more apparent when looking in the Timeline debug panel. The screenshots below show the effects of typing the word “test” in a text input with a row count of 2000.
The gain here is clear. suspendable
is limiting the amount of work being done to update state at a given time and deferring updates to when relevant elements are actually in the viewport.
While suspendable
is currently tweaked for our specific use-case, some trade-offs had to be made. For smoother scrolling, we set the heartbeat time to 2.5 seconds. While this worked for our situation, other interfaces could experience a moment after scroll where elements are presenting a stale version of the model in between heartbeats. 2.5 seconds was the right balance between smooth performance and keeping the DOM representation of the model fresh. Additionally, the “in view” algorithm for suspendable
is simplistic. It doesn’t take z-ordering or overflow into account, so watchers may still exist on elements that are not actually visible.
Conclusion
Angular provides the developer with an incredible set of tools to make applications that are powerful and dynamic. Unchecked, it’s quite easy to shoot yourself in the foot. Understanding the inner workings of Angular is essential before using it for demanding, complex interfaces.
References
[1] https://code.angularjs.org/1.4.7/docs/api/ng/directive/ngRepeat#tracking-and-duplicates
[2] http://www.codelord.net/2014/04/15/improving-ng-repeat-performance-with-track-by/
[3] https://code.angularjs.org/1.4.7/docs/guide/expression#one-time-binding
[4] Response Times: The 3 Important Limits