Attempting To Improve Rendering Performance Of A Large List View In AngularJS 1.2.22
Last week at InVision, we held a company hackathon that produced some amazing ideas across the company. I was on a team that built a "magic link" invite system for enterprise customers. But, as we were integrating the feature, I noticed that the "Team List" page rendering performance was rather appalling. As such, I spent the weekend isolating the team list page (as best I could) so that I could focus specifically on its performance bottlenecks and quickly iterate on possible solutions. Performance profiling is always a fun (and sometimes frustrating) adventure; so, I thought I would share what I did here in AngularJS 1.2.22.
Run this demo in my JavaScript Demos project on GitHub.
View this code in my JavaScript Demos project on GitHub.
WAT?! AngularJS?! For those of you who follow my blog, you may notice that most of my client-side articles use modern Angular, up to and including Angular 9 and the Ivy rendering engine. I freakin' love Angular; and I use it for all of my personal projects. But, at work, I maintain a legacy system that still runs on AngularJS 1.2.22.
What's shocking isn't so much that the system still runs on AngularJS 1.2.22 - what's really shocking is how powerful AngularJS is. Even within the confines of a framework release that's like 6-years old at this point, I still get, what I think is, a ton of high-quality good work done.
With that said, AngularJS definitely has some performance bottlenecks, generally around the number of "watchers" that have been configured in the page. However, the performance of the "team list" page felt disproportionately slow. My gut was telling me that the issue extended beyond a "watcher" problem.
So, I spend Saturday morning recreating the team list page in my JavaScript Demos project, copying (and simplifying) the parts that I felt might be causing issue. Ultimately, I was able to build a 300-item ng-repeat
list that embodied the slowness that I am seeing in production:
<table border="1" cellspacing="0" cellpadding="5">
<thead>
<tr>
<th ng-if="isAdmin"></th>
<th>Avatar</th>
<th>Name</th>
<th>Role</th>
<th>Document</th>
<th>Last Seen</th>
<th>Location</th>
<th ng-if="isAdmin"></th>
</tr>
</thead>
<tbody>
<tr
ng-repeat="person in people track by person.id"
ng-class="{
'is-even': $even,
'is-selected': form.selected[ person.id ]
}">
<td ng-if="isAdmin">
<input
type="checkbox"
ng-model="form.selected[ person.id ]"
/>
</td>
<td ng-if="( person.initials || person.avatarUrl )">
<bn:bad-avatar
bn:person="person"
bn:width="36">
</bn:bad-avatar>
</td>
<td>
<div bn:bad-bind-once>{{ person.name }}</div>
<div bn:bad-bind-once>{{ person.email }}</div>
</td>
<td align="center">
<a ng-click="" bn:bad-bind-once>{{ person.role }}</a>
<!-- Not shown in demo, but DOM elements are present in page. -->
<ul style="display: none ;">
<li
ng-repeat="role in roles"
ng-class="{ 'is-selected-item': ( person.role == role ) }">
<span bn:bad-bind-once>{{ $index }}</span>.
<span bn:bad-bind-once>{{ role }}</span>
</li>
</ul>
</td>
<td align="center">
<div bn:bad-bind-once>{{ person.documents }}</div>
</td>
<td>
<div bn:bad-bind-once>{{ person.lastSeen || "—" }}</div>
</td>
<td>
<div bn:bad-bind-once>{{ person.location }}</div>
</td>
<td ng-if="isAdmin">
<a ng-click="void(0);">actions</a>
</td>
</tr>
</tbody>
</table>
On the face of it, there's nothing very interesting about this AngularJS markup. It has an ng-repeat
directive, some ng-if
directives, some ng-class
directives, an avatar component, and a "bind once" directive. Again, the issue is probably related to the number of watchers - each directive and {{ value }}
interpolation binding represents an additional watcher; but, I suspected that something deeper was going on.
NOTE: The
::bind-once
syntax wasn't added to the framework until AngularJS 1.3; as such, it appears that the author of this list page was attempting to shoehorn that functionality into our version of the AngularJS platform.
Now, if I open this AngularJS application in the browser, and then toggle the rendering of this version of the team list, we can see that the initial rendering performance is, how you say, hot garbage?
As you can see from Chrome's dev-tools warnings, rendering this list takes about 1.8-seconds. That's 1.8-seconds from the time I click the toggle-link to the time I see any changes in the user interface (UI). During this time, the user interface is completely locked up.
To help understand why this is so slow, I opened-up the Chrome Dev Tools Performance profiler and started recording the JavaScript call-stacks while the list was being toggled. Keep in mind that the page runs even slower with the profiler on; but, if we look at the results of the performance profiler, we can see that the "click" action - between the JavaScript processing and the view rendering - took a whopping 2.4-seconds:
When I clicked into the long running task details and open-up the Bottom-Up view, I'm able to sort the various activities by total-time spent. And, immediately, several of the line-items jumped out at me for taking a significant portion of the overall processing time:
Of the outlined items, two of them were completely unexpected: why was setTimeout()
being called? And, why was Parse HTML
showing up at all? By expanding the call-stack in the bottom-up view, I was able to see that some of these suspiciously-slow items were coming out of our custom AngularJS directives:
Let's take a look at what those AngularJS directives were doing. First, the "bind once" directive:
(function( ng, app ) {
"use strict";
app.directive( "bnBadBindOnce", BadBindOnceDirective );
function BadBindOnceDirective() {
return({
link: linkFunction,
restrict: "A",
scope: true
});
function linkFunction( $scope, element ) {
setTimeout(function() {
$scope.$destroy();
element.removeClass('ng-binding ng-scope');
}, 0);
};
}
})( angular, app );
This directive is creating an isolate scope new scope; and then, waiting for a tick to go by (so that the interpolation of the host element takes place) before destroying the isolate scope, thereby unbinding any watchers within the host element. Unfortunately, it seems that the cost of setTimeout()
en masse out-weighs the potential benefits of reducing the number of watchers?
EDIT: Thanks to George Kalpakas for catching my error about the "isolate" scope. I had totally forgot that you could even create a new-scope in an AngularJS directive without creating an isolate scope.
And, here's the other offender, the avatar component:
(function( ng, app ) {
"use strict";
app.directive( "bnBadAvatar", BadAvatarDirective );
function BadAvatarDirective( $compile ) {
return({
link: linkFunction,
restrict: "E"
});
function linkFunction( $scope, element, attributes ) {
var $element = $(element),
expression = attributes.bnPerson,
width = parseInt(attributes.bnWidth, 10) || 36
;
$element
.addClass("avatar")
.css({
height: width,
width: width
});
var unbindWatch = $scope.$watch(
expression,
function(newVal) {
addData(newVal);
},
true
);
// --
function addData(person) {
$scope.avatarPerson = person;
$scope.width = width;
pushTemplate(person);
var $initials = $element.find(".initials");
// Show initials upfront in case image fails
$initials.css({
display: "block",
lineHeight: width + "px",
fontSize: Math.abs( width / 3 ) + "px"
});
var $image = $element.find("img");
// When the image loads, remove the
// background - this is just a nicety
// to remove the jagged color outline
$image.on("load.bnAvatar", function() {
$initials.remove();
$(this).show();
$element.css({
backgroundColor: "none",
background: "none"
});
$image.off("load.bnAvatar");
});
// Prevent broken images by removing it
// from the DOM if an error is detected
$image.on("error.bnAvatar", function() {
$image.remove();
$image.off("error.bnAvatar");
});
}
// I escape unsafe characters for use in the HTML construction.
function htmlEditFormat( value ) {
value = ( value || "" )
.replace( /&/gi, "&" )
.replace( /"/gi, """ )
.replace( /</gi, "<" )
.replace( />/gi, ">" )
;
return( value );
}
function pushTemplate(person) {
var src = person.avatarUrl;
var img = '<img src="' + src + '" ' +
' height="' + width + '" ' +
' width="' + width + '" ' +
' loading="lazy" ' +
' alt="' + htmlEditFormat( person.name ) + '" ' +
' style="display:block;width:' + width + 'px;height:' + width + 'px" />';
var initials = ! person.initials ? '' :
'<span class="initials">' + person.initials + '</span>';
var template = initials + img ;
$element.html(template);
}
};
}
})( angular, app );
This avatar component is overly complicated and is doing too much work. But, at the very bottom, you can see that it is calling the jQuery .html()
method:
$element.html( template );
What this means is that on every single iteration of the ng-repeat
loop-unroller, this directive is parsing HTML. That's a huge no-no when it comes to performance!
Given this evidence, I started to formulate a plan on how to improve the performance of this team list page:
Vastly simplify the way avatars are rendering, removing the concept of the avatar "component", and just unwrapping some of the logic into the team list itself. This includes a custom
ng-src
directive.Remove the
setTimeout()
from the "bind once" directive.Create two versions of the team list view, one for Admins and one for non-Admins. As you can see from the earlier HTML, there are a number of
ng-if
directives for admin-related functionality. The cost of these directives are being multiplied by the number of items in the repeater. If we can create two view, we can remove the embeddedng-if
directives.Remove the
ng-model
directive from the checkbox. The two-way data-binding in this case provides more functionality than we need - a simplechange
handler should do the trick.Defer the nested
ng-repeat
directive. As you can see in the earlier HTML, each row in the team list includes a nestedng-repeat
for a drop-down menu. And, while the menu isn't always visible, the DOM-structure of the menu, including itsng-repeat
is always there. This is massively unnecessary.
NOTE: The above plan didn't just "come to me" when I saw the CPU profiling. Part of it was obvious, like removing the
setTimeout()
calls; however, the overall list of changes was actually based on about 10-hours of trial-and-error over the weekend. Tweaking the code, running the profiler, and then repeating. I'm simply relaying at a list to keep the discussion high-level.
So, first I created a version of the "bind once" directive that didn't include the setTimeout()
and was geared specifically for text interpolation:
(function( ng, app ) {
"use strict";
app.directive( "bnGoodBind", GoodBindDirective );
function GoodBindDirective() {
return({
link: linkFunction,
restrict: "A"
});
function linkFunction( $scope, element, attributes ) {
element[ 0 ].textContent = $scope.$eval( attributes.bnGoodBind );
};
}
})( angular, app );
The reason that the original directive used a setTimeout()
was to give the text interpolation (and other view-bindings) time to reconcile with the view-model. However, since I'm just using this for text-interpolation, I can simplify it down to a single .textContent
assignment. This evaluates the binding expression against the current $scope
; and then stores it into the DOM (Document Object Model) - no timers, no watchers.
I took a similar approach when replacing the ng-src
directive, which is using an attribute observer under the hood. Since I know these values won't be changing during the life-time of the page, there's no need to set the src
property more than once:
(function( ng, app ) {
"use strict";
app.directive( "bnGoodSrc", GoodSrcDirective );
function GoodSrcDirective() {
return({
link: linkFunction,
restrict: "A"
});
function linkFunction( $scope, element, attributes ) {
element[ 0 ].src = $scope.$eval( attributes.bnGoodSrc );
};
}
})( angular, app );
To replace the ng-model
directive, I had to create a change
handler. For some reason, this version of AngularJS doesn't seem to come with one:
(function( ng, app ) {
"use strict";
app.directive( "bnGoodChange", GoodChangeDirective );
function GoodChangeDirective() {
return({
link: linkFunction,
restrict: "A"
});
function linkFunction( $scope, element, attributes ) {
element[ 0 ].addEventListener(
"change",
function handleChange() {
$scope.$apply( attributes.bnGoodChange );
}
);
};
}
})( angular, app );
This directive just listens for the native change
event on the checkbox; and then, evaluates the expression against the current $scope
.
With these new directives ready to go, I created a second version of the team list page. In addition to replacing the old directives with the new ones, I also split the page in two an Admin version and a non-Admin version in an effort to reduce the number of ng-if
directives. I also replaced once of the compound ng-class
directives with a single class
interpolation:
<!-- The ADMIN-ONLY VIEW of the data-table. -->
<table ng-if="isAdmin" border="1" cellspacing="0" cellpadding="5">
<thead>
<tr>
<th></th>
<th>Avatar</th>
<th>Name</th>
<th>Role</th>
<th>Document</th>
<th>Last Seen</th>
<th>Location</th>
<th></th>
</tr>
</thead>
<tbody>
<tr
ng-repeat="person in people track by person.id"
class="{{ $even && 'is-even' }} {{ form.selected[ person.id ] && 'is-selected' }}">
<td>
<input
type="checkbox"
bn:good-change="toggleSelection( person )"
/>
</td>
<td>
<div class="good-avatar" ng-switch="( !! person.avatarUrl )">
<span
ng-switch-when="false"
bn:good-bind="person.initials">
</span>
<img
ng-switch-when="true"
bn:good-src="person.avatarUrl"
loading="lazy"
/>
</div>
</td>
<td>
<div bn:good-bind="person.name"></div>
<div bn:good-bind="person.email"></div>
</td>
<td align="center">
<div ng-click="" bn:good-bind="person.role"></div>
<!-- Not shown in demo, but DOM elements are present in page. -->
<ul ng-if="false" style="display: none ;">
<li
ng-repeat="role in roles"
ng-class="{ 'is-selected-item': ( person.role == role ) }">
<span>{{ $index }}</span>.
<span>{{ role }}</span>
</li>
</ul>
</td>
<td align="center">
<div bn:good-bind="person.documents"></div>
</td>
<td>
<div bn:good-bind="( person.lastSeen || '—' )"></div>
</td>
<td>
<div bn:good-bind="person.location"></div>
</td>
<td>
<a ng-click="void(0);">actions</a>
</td>
</tr>
</tbody>
</table>
<!-- The READ-ONLY VIEW of the data-table. -->
<table ng-if="( ! isAdmin )" border="1" cellspacing="0" cellpadding="5">
<thead>
<tr>
<th>Avatar</th>
<th>Name</th>
<th>Role</th>
<th>Document</th>
<th>Last Seen</th>
<th>Location</th>
</tr>
</thead>
<tbody>
<tr
ng-repeat="person in people track by person.id"
class="{{ $even && 'is-even' }}">
<td>
<div class="good-avatar" ng-switch="( !! person.avatarUrl )">
<span
ng-switch-when="false"
bn:good-bind="person.initials">
</span>
<img
ng-switch-when="true"
bn:good-src="person.avatarUrl"
loading="lazy"
/>
</div>
</td>
<td>
<div bn:good-bind="person.name"></div>
<div bn:good-bind="person.email"></div>
</td>
<td align="center">
<div bn:good-bind="person.role"></div>
</td>
<td align="center">
<div bn:good-bind="person.documents"></div>
</td>
<td>
<div bn:good-bind="( person.lastSeen || '—' )"></div>
</td>
<td>
<div bn:good-bind="person.location"></div>
</td>
</tr>
</tbody>
</table>
With this new AngularjS view in place - including the new versions of various directives - the performance appears to have improved greatly:
As you can see from Chrome's dev-tools warnings, we brought the rendering time down from 1.8-seconds to about 300-milliseconds. That's a significant improvement from a user experience (UX) stand-point.
At this point, I could have stopped. I think I squeezed about as much performance out of this page as I can without getting really customized in how the view is being put together. But, as a fun little extra-credit experiment, I wanted to try one more thing: rendering the list in two phases.
Instead of rendering all 300-items at once, what if I render only what is above the fold of the page first; and then, after a brief delay, render the rest of the list? We've tried approaches like this at work before, and it never really worked out well; but, you can't blame me for being curious.
To do this, I'm going to do three things:
Replace the
people
collection with adeferredPeople
collection in the HTML view.Populate the
deferredPeople
collection with the first 50-items in the list; and then, usingsetTimeout()
, populate the rest of the items into thedeferredPeople
list.Add a "spacer" to the bottom of the view that will mimic the full-height of the populated table during the first rendering phase so that the scrollbar doesn't jump around (too much) and distract the user - we're trying to perform a "magic trick" here and we don't want to draw the user's attention to the magic parts.
Here's the Controller for the "hacky" version of the page:
(function( ng, app ) {
"use strict";
app.controller( "hackyList.Controller", HackyListController );
function HackyListController(
$scope,
data
) {
$scope.people = data.people;
$scope.roles = data.roles;
$scope.isAdmin = true;
$scope.form = {
selected: Object.create( null )
};
$scope.dynamicPeople = null;
setDynamicPeople();
// ---
// PUBLIC METHODS.
// ---
$scope.toggleSelection = function( person ) {
$scope.form.selected[ person.id ] = ! $scope.form.selected[ person.id ];
};
// ---
// PRIVATE METHODS.
// ---
function setDynamicPeople() {
// Start out by rendering only the segment of the list that is "above the
// fold". Then, after a brief rendering pause, render the rest of the list.
$scope.dynamicPeople = $scope.people.slice( 0, 30 );
setTimeout(
function() {
$scope.dynamicPeople = $scope.people;
$scope.$digest();
},
150
);
}
}
})( angular, app );
As you can see, the dynamicPeople
collection initially starts with 30-people in it; then, after a 150-millisecond delay - giving the view-template time to reconcile with the view-model - we just jam the rest of the people
into the deferredPeople
collection.
NOTE: I'm using the
scope.$digest()
method for better performance since it will lower the cost of the subsequent digest to be local to the current component.
And, the only change I had to make in the HTML was to change the reference in the ng-repeat
and add the "spacer" div
:
<table ng-if="isAdmin" border="1" cellspacing="0" cellpadding="5">
<thead>
<!-- .... -->
</thead>
<tbody>
<tr ng-repeat="person in dynamicPeople track by person.id">
<!-- .... -->
</tr>
</tbody>
</table>
<!-- Add bottom-margin to the table so scroll-bars don't jump around visually. -->
<div
ng-if="( people.length != dynamicPeople.length )"
ng-style="{
'height': ( ( ( people.length - dynamicPeople.length ) * 48 ) + 'px' )
}">
</div>
With this two-phase rendering of the list in place, toggling the list feels super snappy:
As you can see, with the two-phased rendering of the list, the initial list appears instantaneously. We can see from the Chrome Dev Tools' warning that the subsequent setTimeout()
handler is still slow (around 200-milliseconds); but, the user will likely not have a chance to interact with the page before this is done rendering.
Not a bad outcome for relaxing weekend full of AngularJS code!
In the vast majority of cases, the performance of AngularJS - even version 1.2.22 - is great. In some cases, like this team list page, however, things can begin to degrade. Some of that is due to poor programming on our part; some of that is due to the way AngularJS performs change-detection under the hood. But, as you can see in this exploration, there is almost always a path forward that can greatly improve performance without resorting to drastic efforts (like rewriting the entire app).
Want to use code from this post? Check out the license.
Reader Comments
Hi,
Have you tried using some virtual scrolling tool? It looks like each item in the list has the same height, which is perfect for virtual scrolling.
Actually, your hack idea is very similar to virtual scrolling, which just partially renders what users can see.
Cheers,
Nice write-up! Really enjoyed it.
100% agree with this take-away. "Always" is a strong word, but this is indeed true much more often than people realize ;)
Tiny correction:
"scope: true" creates a new scope but not an isolate one (it's a regular, non-isolate scope, prototypally inheriting from its parent).
Reference: https://code.angularjs.org/1.2.22/docs/api/ng/service/$compile#-scope-
@Kai,
I know of virtual-scrolling as an idea; but, I have never tried implementing it myself. The one thing that I've always found a little frictional about virtual-scrolling is that I use the
CMD+F
feature in the browser a lot to search the page; and, if the data is not in the rendered page, then you can't find it using that type of search.Obviously, it's a trade-off kind of scenario. Ideally, I'd like to find an interaction pattern in which we can just load less data. But, that's a larger conversation that I would have to have with the "Product people" and the interaction designers.
That said, I should look into it sometime, just to know how it works and keep it in the back of my mind for a possible solution.
@George,
Ah, great catch! I totally forgot that was even an option when it comes to defining directives in AngularJS -- I've spent too much time in Angular 9 at this point. I will add an edit to the post.
@George,
Edit added :thumbs-up:
@All,
While not AngularJS specific, one technique related to large-data loading that I've always wanted to try was using the
IntersectionObserver
to defer template bindings until an Element was within the viewport:www.bennadel.com/blog/3946-using-intersectionobserver-and-ngswitch-to-defer-template-bindings-in-angular-11-0-5.htm
This is just a Proof-of-Concept (POC) that uses the
NgSwitch
directive to manage the templates; and then, uses an attribute directive to pipe (so to speak) theIntersectionObserver
state into theNgSwitch
input. Was pretty cool, actually.