Tuning Mobile Swipe to 60FPS: Part 1

Last year I wrote a blog post about creating a full-page mobile swipe experience with AngularJS. Mobile Zoosk was using my code for a long while, but as in all things, faster is always better. Earlier this year, I was tasked with returning to the project and increasing the average transition speed by 10%, which put us squarely in the domain of 60 FPS.

If this is your first time looking into animations in JavaScript, 60 FPS is regarded as the gold standard for a performant app. Getting your app to perform that quickly means that your JavaScript code needs to take at most 16 ms to run during each frame.

Doing something like that with AngularJS is hard.

The $digest cycle

AngularJS makes coding applications easy using a system of scopes bound to specific elements on the page. Each time your program makes changes, the scopes are marked as dirty, and the engine runs through them all comparing the new values to the old. If values have changed, the DOM gets updated.

Usually, you don’t care about how Angular does its magic. You set some values and they show up on the page. However, if your data changes during something that’s performance-critical, the sheer size of the $digest cycle bogs your app down. In the case of swiping, we need to display a new profile to the user, and that means kicking a $digest.

If you’ve seen my old blog post, you’d know that the fastest way to handle swipe looks like this:

flow-before

The problem here is that we want the user to be able to use the back button in their browser to page backwards through the search results. This means we have to change the URL when going forward, which causes a full-page $digest cycle to kick off. What’s more, displaying the correct data on the correct URL means that we need to have a second $digest cycle that happens after the location is successfully changed.

Parallel Programming to the Rescue

If we were coding a native app instead of a browser, this problem would be easy. Since the user isn’t doing anything during the animation, we can animate in a secondary thread at the same time as the first $digest. That would effectively eliminate the wait time for the user. Unfortunately, we can’t do this in JavaScript because we’re forced onto a single thread.

JavaScript is single-threaded, but it’s important to remember that browsers are not. CSS animations run by themselves in a separate thread, leaving the JS thread free to perform $digest operations. So, if we apply the transformation, then change the URL on the next line of code, the pipeline becomes this:

flow-after

This change alone accounted for a 9% increase in swipe performance, from ~350ms per swipe to ~318 (the animation duration is 300ms). There was just a bit more to go.

Render Time

Each time a JS dev needs to change something in the DOM, the browser goes through three steps:

  1. Scripting. To find out what needs to be changed, your JavaScript function needs to be called. Let’s say we want to change the height of a div. A function would be called that runs “myDiv.style.height = ’50px'”;
  2. Rendering. Now that the height of the div has changed, the browser needs to determine how it’s going to look. It finds the changed object and updates its height in memory. This will cause anything underneath the div to be updated as well, since increasing height bumps everything else on the page down.
  3. Painting. After the browser knows what everything’s going to look like, it goes through its viewport and changes the individual pixels on the screen so that the end user can see the newly tall div.

You may have noticed something particularly worrying in #2. If we make a bad change to the DOM or CSS, it could result in a cascade of re-renders that force the browser to do a ton of math. We’d like to avoid doing that at all costs, and in my last article I removed as much DOM access as I could.

Back then, I looked up the list of performance-safe CSS properties and added a transition class to my element with only those. There’s one thing that I missed, however: adding or removing a CSS class to an element causes a re-render of every child of that element. This isn’t immediately obvious, but it makes sense if you think about it: CSS allows arbitrarily deep nesting of rules, and changing a class might actually change how an element that’s 50+ levels deep looks.

Inline Styles: Finally Okay to Use

Since I already knew that applying a CSS transform to an element was fine, the solution to this problem was simple: apply the rules in the CSS class individually instead of via a class. Doing this meant that the browser could be sure that only my slideshow element had changed, and would only re-render that.

With the CSS fix in place, we got an extra 6 ms of performance, bringing us to 312ms for a full transition. Only one thing remained: painting. We’ll get to that in the next part of this series.