3. 60fps Scrolling

Having scrubbable photos and ideal layouts would not count for much if the browser couldn’t handle it. Which, by itself, it actually can’t — fortunately we can help.

One of the biggest ways that websites can feel slow (other than initial load times) is in how smoothly they respond to user interaction, especially scrolling. Browsers try to redraw the contents of the screen 60 times every second (60fps) and when they’re successful it looks and feels very smooth — when they don’t it can feel janky.

To maintain 60fps each update needs to be rendered in a mere 16ms (1/60) and the browser needs some of that time for itself — it has to marshal the events, parse style information, calculate layouts, convert all the elements into pixels, and finally draw them to the screen — that leaves around 10ms for an app to do its own work.

Within those 10ms, applications need to be both efficient in what they do, as well as careful not to make the browser perform unnecessary work.

Maintaining a constant-size DOM

One of the worst things for page performance is having too many elements. The problem is two fold: it consumes more memory for the browser (eg at 50KB thumbnails 1000 photos is 50megabytes, the 10000 photos that used to crash Chrome was half a gigabyte); additionally, it is more individual pieces the browser needs to compute the styles and positions for, and composite during layout.

We remove all unnecessary elements

While most users will have thousands of photos in their library the screen can usually only fit a few dozen.

So, instead of placing every photo into the page and keeping it there, every time the user scrolls we calculate what photos should be visible and make sure they are in the document.

For any photo that used to be in the document but is no longer visible we pull it back out.

While scrolling the page there are probably never more than 50 photos present, even as you scroll through tens of thousands. This keeps the page snappy at all times and prevents crashing the tab.

And, because we group photos into segments and sections we can often take a shortcut and detach entire groups instead of each individual photo.

Minimizing changes

There are some great articles on the Google Developers site about rendering performance and how to use the powerful analysis tools that are built into Google Chrome — I’ll touch on a few aspects here as it applies to photos, but the other write-ups are well worth a read. The first thing to understand is the page rendering life-cycle:

The (Chrome) pixel pipeline

Every time there is a change to the page (usually triggered by JavaScript, but sometimes CSS styles or animations) then the browser checks what styles apply to the affected elements, recalculates their layouts (the size and positions), and then paints all the elements (ie converts text, images, etc… to pixels). For efficiency the browser usually breaks the page into different sections it calls layers and paints these separately, and so a final step of compositing (arranging) those layers is performed.

Most of the time you never need to think about this, the browser is pretty clever, but if you keep changing the page on it (for example constantly adding or removing photos) then you need to be efficient in how you do that.

Sections, segments, and tiles are positioning absolutely

One way we minimize updates is by positioning everything relative to its parent. Sections are positioned absolutely relative to the grid, segments are positioned absolutely relative to their section, and tiles (the photos) are positioned absolutely relative to the segment.

What this means is that when we need to move a section because the estimated and actual layout heights were different, instead of needing to make hundreds (or thousands) of changes to every photo that was below it, we need only update the top position of the following sections. This structure helps isolate each part of the grid from unnecessary updates.

Modern CSS even provides a way to let the browser know — the contain keyword lets you indicate to what degree an element can be considered independently of the DOM. We annotate the sections and segments accordingly.

There are some easy performance pitfalls as well, for example the scroll event can fire multiple times within a single frame, and the same for resize. There is no need to force the browser to recalculate style and layout for the first events if you will change them a second time anyway.

Fortunately there is a handy way to avoid that. You can ask the browser to execute a specific function before the next repaint by using window.requestAnimationFrame(callback). In the scroll and resize handlers we use this to schedule a single callback instead of immediately updating — for resize we go a step further and delay updating for half a second until the user has settled on the final window size.

The second common pitfall is something known as layout thrashing. Once the browser has calculated layout, it caches it, and so you can happily request the width, height, or position of any element pretty quickly. However if you make any changes to properties that could affect layout (eg width, height, top, left) you immediately invalidate that cache, and if you try to read one of those properties again the browser will be forced to recalculate the layout (perhaps multiple times in the same frame).

Where this can really cause problems is in loops with updates to many elements (eg hundreds of photos), if each loop you read one of the layout properties, then change them (say moving photos or sections to the correct spots), then you are triggering a new layout calculation for every step in the loop.

The simple way to avoid this is to first read all the values you need, and then write all the values (ie batch and separate reads from writes). For our case we avoid ever reading the values, and instead keep track of the size and position that every photo should be in, and absolutely position them all. On scroll or resize we can re-run all our calculations based on the positions we have been tracking, and safely update knowing that we will never thrash. Here is what a typical scroll frame looks like (everything is only called once):

Rendering and Painting event order for a typical scroll update

Avoiding long running code

With the exception of Web Workers, and some of the native async handlers like the Fetch API, everything in a tab essentially runs on the same thread — both rendering and JavaScript. That means any code a developer runs will prevent the page from redrawing until it completes — for example a long-running scroll event handler.

The two most time-consuming things our grid does is layout and element creation. For both we try to limit them to the essential operations.

For example, the layout algorithm takes 10ms for 1000 photos and 50ms for 10000 — this could use up our entire frame allowance. However given we subdivide our grid into sections and segments we usually only need to layout a few hundred photos at any time (which takes 2–3ms).

The most “costly” layout event should be a browser resize, because that would need us to re-calculate the sizes of every section. Instead we fall back to the simple estimate calculation, even for loaded sections, and only perform the full FlexLayout for the presently visible section. We can then defer the complete layout calculation for the other sections until we scroll back to them.

The same happens with element creation—we only create the photo tiles just before we need them.

Result

The end result of all the hard work is a grid that can maintain 60fps the majority of the time, even if it occasionally drops some frames.

These dropped frames usually occur when a major layout event happens (such as inserting a brand new section) or occasionally when the browser performs garbage-collection on very old elements.