Website Performance: UI Rendering Optimization
We use our own product Sprintly a ton and love it, but over time we noticed our interface slowing down as it tried to render large lists that would contain hundreds or even thousands of items. As Donald Knuth once said, “Premature optimization is the root of all evil.” so we knew we needed to focus on building a great product over performance early on, but now that our product has grown and customer base is maturing, it’s time to take on making Sprintly fast.
Over the last few weeks we built blazingly fast and performant item columns…even when rendering thousands of items! Check it out:
The update was tweeted about from the main Sprintly account. Immediately we were asked to explain how we accomplished this, so I’ll go through and discuss the main approaches to building performant interfaces and explain why we decided to tackle it the way we did.
Finding the Cause of Slow UI Performance
Debugging web interfaces is a challenge, but the tools have come a long ways. Our favorite way to debug is with the Chrome developer tools. Check the “Profiles” and “Timelines” tabs. You can view memory usage, debug slow JavaScript calls or even debug browser repaint and framerate issues.
It’s amazing what kinds of things Chrome dev tools can expose, but in our case it was blatantly obvious we needed to tackle how the item columns render. In the video you’ll see the old lists could take almost a minute to load thousands of items. In the new version, instead of rendering all the items at once, we developed a way to lazy load items as they came into view.
Slow Rendering of Backbone.JS Views
We use Backbone.JS for our application. The most common issue I see when using Backbone is that it’s encouraged to have views render themselves. That’s usually fine, but when you start to have large collections of view to render and you loop through them to render views individually, you are actually forcing the browser to repaint and render with each item injected into the page. Like I said, it’s not a problem if you are only rendering a few view, but it adds up quickly to become a performance bottleneck.
Example of the wrong way to render collections:
This would insert the view into the DOM 1000 times. Bad thing to do. And in our case this ItemView has many other subviews, so the issue was compounded by each subview added.
There’s a few main approaches you can take to tackle fixing rendering issues.
- Pre-render the HTML (server-side)
- Batch render the views
- Lazy-load the views
I’ll walk through each of these approaches at a high level and give reasons why we settled on #3 for large lists of items.
Approach #1: Pre-rendering HTML
Performance Benefit:
One of the biggest benefits to pre-rending all of your HTML is with public websites that need to be indexed by search engines (note: you can make your AJAX application crawlable, but it isn’t automatic).
The other main advantage is that the client doesn’t have the initial overhead of processing merging of JSON and HTML templates.
Implementation:
Render your entire page on the server and then bind views to the existing HTML fragments later. This is the basic principle behind Twitter’s new Flight framework. Just find the elements and then bind the views to them after the page loads to attach behavior.
Why we didn’t chose to pre-render HTML:
The major caveat to this approach is you have to duplicate your rendering logic to the server. We didn’t chose this method both because the contents of our application doesn’t need to be indexed by search engines and all of our rendering currently happens in mustache templates, so it would take a fairly large revamp of our current codebase to migrate to something like Node.JS to render the templates on the server. It might be a good approach longterm, but we wanted a quicker win.
Approach #2: Batch Render Views
Performance Benefit:
Instead of rendering each individual module on the page, you can decide to load all the HTML up into some type of string, array or detached DOM node and then insert then insert your views into the page all at once. This leads to touching the DOM less and, in turn, the less times the browser has to repaint itself.
Implementation:
Render all of your HTML into a string, array, or detached DOM node that will be inserted into the page at a later time.
Why we didn’t chose to batch render views:
Well, technically, I did this where I could. Batch rendering is always a good idea and you should do it whenever possible. But in this case we weren’t able to batch render for list items because we’re using D3.js to bind data to DOM nodes and sort the nodes in the DOM. It works like magic, but initially D3 wants you to render out at least one node per data element. To get around this we render placeholders (I’ll get into that next).
Approach #3: Lazy-Loading Views
Performance Benefit:
You don’t have to render all the items on initial page load. Instead, load them as you need them.
Implementation:
Render enough to paint the current view, then as things come into view (usually on scroll), render items enough to fill the window.
Why we chose to lazy load views:
I explained the main benefit is that you don’t have to render all the views all out at once. You can load a subset and then load the rest later.
The trick with getting this to work is you always have to determine what is in the view and render the appropriate views.
Checking What’s in the View on Scroll
Looking into the current view for what to render can get expensive, so we don’t want to do this on every scroll event. So we use a concept called debouncing. Check out the debounce method in underscore.js. What happens is instead of firing an event every time you scroll, you just fire it at a maximum of a certain amount of milliseconds.
So with debouncing we can run a method to check the view max of every 150 milliseconds and not slow down the interface with excess function calls.
Smart Rendering with requestAnimationFrame
Enter requestAnimationFrame. With the requestAnimationFrame polyfill, we can wait to render until the browser is ready to paint the next animation frame in a cross browser way. Using rAF prevents DOM manipulation updates from *stacking* and slowing down the UI. Game developers might be familiar with a runloop where changes are flushed…that’s what’s happening here. Frameworks like Ember.js are starting to incorporating this runloop concept where DOM changes are written to a buffer and then *flushed* on each frame.
So that’s how we lazy load list items efficiently. We use function debouncing with requestAnimationFrame to prevent the view check from happening too frequent, and to wait for the next frame available to render.
Finding Un-rendered Elements in View
So far I haven’t dived into what’s in getItemsInView() function, but it’s actually simpler than you might think at first. Since we are rendering un-rendered items in a list, we just have to find the top element in the view and the bottom element in the view and render those. We can do that with document.elementFromPoint function. elementFromPoint takes an “x” and a “y” param and tells you what element is showing at that point.
And there we have it! Using batch rendering, lazy loading and requestAnimationFrame, we’ve got a smooth interface that only renders what it needs to.
About the Author: Marc Grabanski is a UI/UX Developer contractor working on Sprintly, you can find him on Twitter here.