Optimizing AngularJS 1.* for Spotful

Here at Spotful, the base of our online editor is built on AngularJS.  For the past couple of weeks, we’ve had a couple of our developers focusing on performance.  Optimizing Angular is a challenge and is something we’re learning more and more about as a dev. team with every passing week.  Our guys just deployed their first set of major updates and have kindly written up a summary for our cousins to learn from.

  So without further ado, I give it over to our resident wizard level 9, Edmond Belliveau.

– Dan Lazar, Head of technology @ Spotful


TOC

  • Reduce latency of static closures and function calls
  • Reduce amount of watchers per digest cycle to reduce digest cycle latency
  • Know when to $digest, and when to $apply()
  • Conclusion: The Before vs After

Reduce latency of static closures and function calls

In any JavaScript application, the overall speed or responsiveness of your application lies in how you implement complex logic and where it is decoupled from the DOM. With AngularJS, utilizing directives or components incorrectly are a great way to accidentally bloat your app’s draw and refresh cycles.

Closures are an essential part of Javascript; they are garbage collector-compliant, which means that the browser will automagically clean up any stale references upon parent dereferencing. Knowing how to trim the fat on closures within the scope of an AngularJS project will undoubtedly help you.

  • Use CSS classes as much as possible instead of JavaScript for animations within the DOM
    • CSS is optimized to eek out every bit of performance within the browser for any and all animation events and transitions within the DOM. In many cases, the browser has CSS optimized down to the GPU layer. It is a silly and gratuitously slow means of implementing animations using Javascript — you simply cannot hope as a single developer to out-wit the folks who are building the browsers that your users use.
  • Ditch those $('.bloody') selectors.
    • Practically every Javascript framework comes with their own internal methods which allow the developer to invoke a selector to acquire a reference to a DOM element. Each and every one of these is slower than native document.getElementById calls — and, in many cases — orders of magnitude slower. Be mindful of how you acquire references to your DOM elements before just accepting that $("div.something [my-attrib]") is the best way of having your code navigate the DOM. The best approach in many cases is simply to ditch your external library’s selector usage and rely on vanilla Javascript to accomplish the task. The take-away from this is that more performant code does not always have to be the latest ES6+-compliant back-compiled new wave JS. Sometimes, simple is sublime – and in the realm of DOM selectors, MANY people get this wrong (or, in the case of young, fresh web developers — many implement this inefficiently).
  • Learn to store local references to your in-browser or in-DOM objects and batch updates to them sparingly.
    • It is a very common task to iterate over a collection of items and use their properties to modify DOM elements. By storing a local variable of your reference and by being mindful of the hierarchy of object parsing, you can rapidly reduce the amount of calls to reference the DOM and ultimately reduce the latency of your application’s draw and redraw cycles.
/*
    In this example, we are collecting a Cast of Characters from the CastService.
    By iterating over the <Cast> collection, we can produce a metric which will
    allow us to display the total career experience of the <Cast> collection, which is
    a useless and interesting metric.
*/

The following example:

var Cast = CastService.getFullCastOfCharacters("FullHouse"); //Update the list of Cast members
for(var Character in Cast) {
    var experienceCell = $("div." + Character.id + "[" + Character.name + "]"); 
                                                //Acquire the reference to 
                                                //the cell we're going to populate.
    // Populate the Character's cell on the page and add that to the
    // Cast collection's experience property, ensuring numeric casting
    Cast.experience += Number(ageCell.html(Character.careerExperience).text());
    var globalDomContainer = $("div.CastContainer");
    globalDomContainer.append(ageCell);
}
// some other logic to display the experience

is slower than:

var Cast = CastService.getFullCastOfCharacters("FullHouse"); //Update the list of Cast members
var globalDomContainer = $("div.CastContainer");
var experienceCells = $("div."); //Acquire the reference to 
                                 //the collection we're going to populate.
for(var Character in Cast) {
    var experienceCell = experienceCells[experienceCells.index(Character.id)];
    experienceCell.html(Character.careerExperience);
    globalDomContainer.append(ageCell);
    // Populate the Character's cell on the page and add that to the
    // Cast collection's experience property, ensuring numeric casting
    Cast.experience += Number(Character.careerExperience);
}
// some other logic to display the experience

is still slower than:

var frag = document.createDocumentFragment(); // This guy is the key here.
var globalDomContainer = document.getElementById("CastContainer");
var Cast = CastService.getFullCastOfCharacters("FullHouse"); //Update the list of Cast members
for(var Character in Cast) {
    // Append the career experience for each Character in the Cast to the document fragment.
    frag.appendChild(Character.careerExperience);
    // Populate the Character's cell on the page and add that to the
    // Cast collection's experience property, ensuring numeric casting
    Cast.experience += Number(Character.careerExperience);
}
globalDomContainer.appendChild(frag);
// other bits and bobs

All of this goes to illustrate the benefits gained from gathering your references early. With large collections and many draw or refresh cycles, it is often best to batch your DOM updates and only rely on queries to the DOM to gather references early. In the final snippet, we see the use of the documentFragment — this creates a shadow DOM node where the elements being dropped into the main DOM are able to be batched through in a single call, reducing the number of redraw cycles from Cast.lengthdown to 1.

Reduce amount of watchers per digest cycle to reduce digest cycle latency

In simple terms, an AngularJS watch is a piece of code which will parse the object model of your angular directive or component’s $scope. In each digest cycle (the process of collecting and mapping the changes in the scope back to changes needed to be represented in the DOM and elsewhere), the watch stack is evaluated top to bottom. It’s simple math — having a smaller, more restricted stack of one-way watchers is key to reducing your AngularJS application’s overall latency.

By utilizing shallow watches on large, sometimes-nested objects, you can effectively reduce or debounce the latency with which your double-bound variables need to be updated:

  • Use a single (or ideally as few as possible) variable for tracking a visible change in your DOM.
  • ng-show and ng-hide will be treated as high resolution watch cycles, so be sure that you are comfortable with the performance hits these will induce.
    • Understand that by using ng-show and ng-hide, you are committing to using the variables contained in the respective watches on a single instance of the DOM element with which it is bound to. Updates will be cascaded downwards so by using these, you are committing to allowing the DOM to be updated inside the visible/hidden element. One way to mitigate this is to know when to apply each. A good discussion is available on StackOverflow discussing the merits/pitfalls of each.

Know when to $digest, and when to $apply()

As with ng-if versus ng-show/ng-hide, knowing when to tell your $scope to process its watch cycles downward is the paragon of an efficient AngularJS app. Here’s the skinny:

  • $digest():
    • Instructs everything at the same level as your calling scope to invoke a digest cycle and propagate the consumption of the changed/updated values from the invocation point DOWNWARD in the scope tree.
  • $apply():
    • Instructs everything at the $rootScope level — that is, the whole application — to invoke their digest cycles from top to bottom in the scope tree.

It is useful to first understand the scope hierarchy in your AngularJS’ call stack. Here is a screen capture from the AngularJS Batarang tool in one of our applications:

scope-tree-1

Above is an example output from Batarang showing the nature of the parse tree with which Angular 1.x uses to isolate and nest scopes. You can see a few of the items in the scope are nested at the same level. This allows these items to optionally share scope data throughout their lifecycle. In this case, we’re looking at an Angular 1.5 stack, with a few items contained in an ng-repeat. All of this lives doubly within the Angular digest stack as well as the DOM; knowing that nesting elements too deep will undoubtedly increase the latency and intensiveness of querying the DOM, you should also be careful in how deep you nest your directives.

Conclusion: The Before vs After.

After a gruelling couple of weeks working with one of the craziest Angular devs at my firm, we’ve come up with our first phase of optimizations to the player following the above tenets. The differences are rather difficult to glean at first glance, so I’ll tabulate them below.

But first, some screenshots!

before-mobile-1.png

after-mobile-1.png

loading-performance-before.png

loading-performance-after.png

It might be difficult to read in these screenshots, so I’ll break it down below. The take-away here is that there are no longer any stupidly heavy digest cycles causing slow frames on load. The majority of the latency seen in the loading and initial play of the videos is actually constrained to the upstream player! We did it!!

MetricBeforeAfterGains
Digest Latency~3.04ms~0.08ms>350% (3.04ms / 0.08ms = ~38x faster)
Maximum #listeners per Digest~280~30On average, roughly 90% of our active angular $watch() listeners have been refactored out.
Minimum #listeners per Digest~180~3On average, roughly 60% of our passive angular $watch() listeners have been refactored out.
Heap memory use after onLoad settles69MB46MBDepending on the project being loaded, anywhere from 20-40MB have been cut from the active heap memory use during player initialization.
Page load time7.81s4.75s~50% load times observed.

Thanks for reading!

– Edmond Belliveau, Software Engineer and Wizard level 9 @ Spotful


Spotful Loading animation plays during video load.