Adventures in WebKit land

A blog about mobile and desktop front-end

Fixing a parallax scrolling website to run in 60 FPS

I recently visited a parallax scrolling website that was just one of thousands of different parallax scrolling websites out there. One thing that I immediately noticed was that the scrolling FPS of that page was really bad. I suspected that there would be room for improvement in their parallax implementation, and I wanted to take a look to see if there was anything I could do to improve it.

I’m not going to link to that website or tell you its name. Why? Simply because it is a live website and they can change their parallax implementation at any moment, and that would probably distract people reading this blog post.

I was interested to see what I could do to increase performance by modifying the original code as little as possible. I’ll show you five fixes that give the website a nice performance boost.

Finding out what is wrong

First of all, what causes bad scrolling performance (low FPS)? Well, that is a tricky question. It can be caused by almost anything related to your page: images, CSS or Javascript (or all of them).

HTML5 rocks article called “Scrolling Performance” gives some explanation for low parallax scrolling FPS:

…Whenever you scroll a page the browser will probably need to paint some of the pixels in those layers (sometimes called compositor layers). By grouping things into layers we need only update that specific layer’s texture when something inside that specific layer has changed, and where we can we want to only paint and rasterize the part of a render layer’s texture that’s been damaged rather than the whole thing. Obviously if you have things moving as you scroll, like in a parallax site, then you’re potentially damaging a large area, possibly across multiple layers, and this can result in a lot of expensive paint work.

– Paul Lewis, in Scrolling Performance

Chrome’s developer tools help a lot when investigating problems like this, so I used Cmd + Opt + J (Ctrl + Shift + J on Windows) to open dev tools on the page and went to “Timeline” and clicked “Frames”.

This is the initial FPS recording of the website:*

It is better that I explain what is going on here.

The x-axis of the image: represents time, in this case something close to 5 seconds of scrolling.

The y-axis of the image: green color of the bars indicate that the browser mostly spends time on doing “paint”: painting the content on user’s screen. Longer bars (that go above the 30 FPS mark) happen when a parallax background image gets shown. The bad thing is that there are three long, low FPS bars (< 30 FPS) in a row for each parallax image. That makes scrolling feel sluggish.

*If you are not familiar with the timeline view in Chrome dev tools, take a look at the official documentation (helps to understand the images in this blog post).

The parallax technique used

Let me try to explain the parallax scrolling technique that the website is using.

There are div elements on the page that all share the same .parallax-bg CSS class. On Javascript side jQuery is used to loop through those elements and read a data- attribute that holds a filename for an image.

Each parallax background image is then asynchronously loaded with Javascript by creating a new img element and setting the filename to its src attribute.

Created image elements get appended to a new div element that is appended to the page. Each new div element has CSS transform: translate3d(0px, y, 0px); set, where y is the vertical position for parallax effect in pixels (e.g. -620px).

Inside a scroll event handler there is a “visibility” check, so that only one parallax image is visible when user scrolls. On scroll translate3d’s y value is updated for only the currently visible image (creating a parallax effect).

Fix 1. Don’t put scroll/resize event handlers inside a loop

Looking at the code, I could see that there had been some effort in trying to do the correct thing by caching image references and then attaching one scroll event listener that looped through all parallax images.

…but that code looked unfinished and was commented out. Looks like the developer(s) ran out of time and instead decided to attach the $(window).on('scroll') and $(window).on('resize') event handlers for each parallax image inside a $('.parallax-bg').each(function() {}); loop.

This means that every time you scroll (or resize), an event handler is called for each of the images. Depending on what kind of work you do inside your scroll event handler, this can potentially be bad for performance.

Here is the code that loops through the images that are on the page. Yeah, I’ve stripped out all the unrelated code to keep it clean.

scroll and resize event handlers placed inside a loop
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
$('.parallax-bg').each(function(index) {
  var parallaxImage = $(this);
  var height = parallaxImage.height();
  // More...
  // Code to get element values...
  // etc..

  // Oh noes! We are inside a loop, so the following 
  // code will create a new handler for each image!

  $(window).on('resize', function() {
      height = parallaxImage.height();
      // Code to update element values...
  });

  $(window).on('scroll', function() {
      // Logic to see which image should currently be shown...
      // Code to update `transform: translate3d` value...
  });
});

By putting a console.log inside the scroll event handler and then scrolling, the console tab gets flooded with messages from each event handler:

To fix it the code needs to be changed a bit, so here is my suggestion. I’ve written fixes as Javascript comments and marked them with numbers 1. - 6.

Fix: Move event handlers outside the loop
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// 1. Make an array that can hold the parallax image objects
var parallaxImages = [];

$('.parallax-bg').each(function(index) {
  // 2. Inside the the loop make a new object to hold each image
  var parallaxImage = {};
  // 3. Save the information that you need from the image to that object
  parallaxImage.element = $(this);
  parallaxImage.height = parallaxImage.element.height();

  // More...
  // Code to save element values...
  // etc..

  // 4. At the end of the loop push the object to the `parallaxImages` array
  parallaxImages.push(parallaxImage);
});

// 5. Move both event listeners outside the `$('.parallax-bg').each` loop

$(window).on('resize', function() {
  // Code to update element values...
});

$(window).on('scroll', function() {
  // 6. Inside the event handler we loop each cached image object from the array
  $.each(parallaxImages, function(index, parallaxImage) {
      // Logic to see which image should currently be shown...
      // Code to update `transform: translate3d` value...
  });
});

Great! now only one event handler gets called when you scroll. On scroll the parallaxImages array is looped and we can use the existing check to see if the image should be visible or not.

Fix 2. use requestAnimationFrame for resize and scroll event handlers

It might not be that obvious, but attaching a scroll event handler can in some cases be really costly for your website’s performance. The reason for this is that by default the event handler gets called a lot, possibly several times per a frame when you are scrolling the page. If you are reading/writing values to the DOM or updating layout of your page directly in your scroll event handler, it will usually have a bad impact on the scrolling performance.

So what is the deal with requestAnimationFrame? It basically allows the browser to run your event handler (or any other function that runs frequently) when it is free to do new paint work on the screen.

“The window.requestAnimationFrame() method tells the browser that you wish to perform an animation and requests that the browser call a specified function to update an animation before the next repaint. The method takes as an argument a callback to be invoked before the repaint.” - MDN

Let’s see what Paul Irish has to say about scroll and resize event handlers and requestAnimationFrame:

There are only a few things you should ever do inside a resize event or a scroll event… …inside a scroll event you can ask for, lets say, the scrollTop() metric… you can also ask for the time… and that’s about it. You do not want to do any other work inside the handler! Do the work that you want to do inside a requestAnimationFrame()

– Paul Irish, in Chrome Office Hours: Performance

The fix is simple: just include requestAnimationFrame polyfill for better cross-browser support, and give your scroll event handler to requestAnimationFrame.

requestAnimationFrame example
1
2
3
$(window).on('scroll', function() {
   window.requestAnimationFrame(scrollHandler);
});

After using a single scroll event handler + requestAnimationFrame, the timeline recording started to look a bit better already:

Fix 3. Avoid resizing big images

After first two fixes the timeline still has huge spikes (FPS drops) every time a new parallax image is shown. A Recording shows why:

Long green bars indicate, that a lot of time (150-200ms) is spent on painting a big area of the screen: resizing, decoding and showing a JPEG image. The timeline recording shows a painted pixel size of 1351x469 pixels. Inspecting the images reveals that all parallax images have a resolution of 3000x2000 pixels, so the actual painted area is much smaller than the resolution of the image. This means that the browser needs to do extra work to show the image on screen.

…If you’re sending large images to a device and then scaling them down using CSS or image dimension attributes, you’re more likely to see this happen. Of course the amount by which the browser has to rescale images, and the frequency with which it has to do this, is going to affect your page’s performance as they happen on the main browser thread, and therefore block other work from taking place. The case for sending images that can be rendered without any resizing, therefore, is very strong.

– Paul Lewis, in Scrolling Performance

Rather than resizing a big image, you could create at least 6-10 different resolutions of the same image (you can look at your website’s analytics to see what are the most common resolutions), and use a responsive image solution, like Picturefill to serve an image that is closest to visitor’s current browser window size.

I did a quick fix by just resizing all images to match my 1360x768 resolution. This caused the JPEG decoding time to drop from ~50ms to 30-35ms for each image. I know that it does not sound like a huge win in terms of time, but it actually helps in enabling next fix (fix 4).

Fix 4. Remove background-size CSS property if it slows down your website

Using background-size: 100% is good if you want to make sure that your background images automatically fit nicely to match the size of visitor’s browser window. If you use it for your parallax background images, those images probably have to be resized to fit your screen every time an image is shown on the screen, which is bad for performance.

But if you use some kind of responsive image solution, there might not even be a need to use background-size, so remove it from your code to get a nice performance boost!

Let’s take a new recording and see how it looks like:

Bonus fix: experiment with translateZ(0) on the images

Using CSS transform: translateZ(0); on the images is something that can further increase performance, but I don’t recommend using it unless you are fully aware of what it does. Basically, it is considered to be a hack that usually forces the rendering to happen on GPU.

…Many times people just use the -webkit-transform: translateZ(0); hack and see magical performance improvements, and while this works today there are problems: 1. It’s not cross-browser compatible. 2. It forces the browser’s hand by creating a new layer for every transformed element. Lots of layers can bring other performance bottlenecks, so use sparingly! 3. It’s been disabled for some WebKit ports.

– Paul Lewis, in Parallaxin’

In my case adding the transform: translateZ(0); hack to parallax images increased overall FPS in Chrome, but in some other case it could possibly create performance issues, so beware!

What about other fixes?

I could possibly make further performance optimizations by doing a refactor for the website’s code, but honestly I don’t have time and really don’t feel like doing that.

One thing that I wasn’t sure about was that is it possible somehow to get rid of the previously mentioned ~35ms image decoding cost when an image gets shown by the browser (display: none -> display: block). Looks like that is causing the remaining spikes that are visible in timeline recordings.

Conclusion (tl;dr)

Low parallax scrolling FPS is relatively easy to increase once you are able to recognize possible performance bottlenecks.

By combining all of the performance improvement tricks mentioned in this post, I managed to increase the overall FPS of the website so that the FPS drops only a couple of times shortly to 50 or 40, but does not drop under 30 at any point. This means that there are no more hiccups when scrolling.

FPS before any fixes* After single scroll event handler + rAF* After bg image fix and removing background-size* After translateZ(0), all fixes*

*Widths of the green bars differ a bit because the scrolling time and distance was not _exactly_ the same every time.

Resources

Parallax related

Paul&Paul investigating rendering problems

Layout and rendering

Image rendering performance

requestAnimationFrame


EDIT: 2013-09-01 - Small spelling & grammar fixes

Dealing with SVG images in mobile browsers

When browsing the web with my retina iPad, I often see websites that could have used SVG for their cartoon-like graphics, but used PNG instead. It seems weird, because most likely those images have been created with some vector graphics editor and then exported or converted to bitmap images.

SVG has been supported in most browsers for years, but still it seems that developers are not yet comfortable with using SVG images on their websites.

SVG is quite well-supported in mobile browsers. This means that you can link to a SVG file on your page in most mobile browsers and it just works. But… there is one big problem: Android versions under 3 don’t have any kind of support for SVG in the stock browser. Desktop browsers have a similar situation with older Internet Explorer versions not supporting SVG.

Using SVG on older Android versions

Todd Anglin wrote a very good post on Kendo UI blog a year ago on how to deal with the situation on Android 2.X. In the blog post he describes how you can polyfill Android 2.X’s SVG support by using a javascript library to render SVG on a HTML5 canvas element.

In his post he writes that according to Google’s stats 94% of the Android users use version 2.1, 2.2 or 2.3. Fortunately the situation has improved a lot in a year, and that percentage is ~54% at the time of writing this article. Well, 54% of all Android users is still a huge amount of people!

Polyfilling missing SVG support with Canvas

You can use Modernizr and Canvg together to provide fallback(s) for SVG:

Checking for SVG and Canvas support, polyfilling if needed > JSBin demo
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// In this example the SVG image is included as a string (mySVGImage),
// you could also link to a file (myimage.svg).

if (!Modernizr.svg) {
  if (Modernizr.canvas) {
      var canvas = document.createElement("canvas");
      canvas.setAttribute("style", "height:500px;width:300px;");
      canvg(canvas, mySVGImage);
      document.body.appendChild(canvas);
  } else {
      // Do something else, perhaps a fallback to PNG?
  }
} else {
  var svgObj = document.createElement('object');
  svgObj.setAttribute('type', 'image/svg+xml');
  svgObj.setAttribute('data', 'data:image/svg+xml,' + mySVGImage);
  svgObj.setAttribute('height', '500');
  svgObj.setAttribute('width', '300');
  document.body.appendChild(svgObj);
}

This is just a basic example, so if you use it, you should add more logic to make the width and height values more flexible, and for example polyfill missing Canvas support or add a fallback to a PNG image.

Polyfilling SVG with Canvas demo

Here is a link to a JSBin example that shows the polyfilling in full action: http://jsbin.com/ujujuw/1/edit

Performance testing SVG to Canvas rendering

When I heard about the canvas rendering technique, I wanted to test if it is actually performant enough, knowing that SVG images might be a bit slow to render on slower mobile devices.

To get a good idea of the performance, the rendering speed needed to be tested with a really slow device. ZTE Blade was perfect for this kind of testing, having Android 2.2, 512Mb of RAM and a CPU with clock speed of only 600mhz. I ran some browser performance tests on it earlier, so I knew that it is slow.

For now I tested the polyfill performance with only one device, but I might revisit the testing with more 2.X devices when I have a bit of extra time.

For a test image I used this SVG image of map of Finland, that was rendered in 300x500 px resolution. I had an empty canvas element appended to body element and used Canvg library to render the stringified SVG image data to the canvas element.

The test results were quite good for a device as slow as ZTE Blade. It would render the SVG map of Finland in 1.3 - 1.4 seconds. What the result means is that you should be able to use this technique to render at least 1-2 SVG images even on slower Android devices without making the user wait for too long.

Getting the SVG image out of Canvas

HTML5 Canvas element usually has a toDataUrl method that allows you to get the image data out of canvas as a data URL. Newer Android versions and iOS support this method, but unfortunately it does not work in Android 2.X stock browser.

I was looking for a workaround for this, and found a javascript library called todataurl-png-js that can be used to polyfill toDataUrl method for PNG images on Android 2.X.

If you read the todataurl-png-js tutorial you might notice this:

It’s slow. There’s just no way a JS implemented method can keep up with a native one. Plus, the PNG format wasn’t created to be fast: it needs two checksums in order to create a working file and neither of these methods is implemented in a browser’s native code.

A quick performance test clearly demonstrates the awful speed of it.

It took 72 seconds on ZTE Blade to:

  1. First render the map of Finland to the canvas element
  2. Then to get the image data URL out of canvas with polyfilled toDataUrl method (this step took over 70 seconds)
  3. Then to create an image with the data URL as source and append it to the test page.

Converting SVG images to @font-face icons

Since SVG on Android is more or less broken (unless you want to ignore over half of Android users), you can use @font-face icons to replace at least some of your SVG images. This is not a great workaround for the problem, because Android 2.1 stock browser, Windows Phone 7 IE9, Opera Mini and some other browsers do not support @font-face. @font-face also only works for SVG images that are “simple shapes”. You can’t use it to render complex images.

Rendering shapes or icons with @font-face has many limitations, but it is still a valid option in many cases.


– Using @font-face fonts has an additional benefit for desktop browsers: old Internet Explorer versions do not support SVG, but support @font-face fonts.

FontCustom

FontCustom is a command line tool that allows you to convert a bunch of SVG files to a @font-face icon font.

Installation on Mac OS X is easy, but you need to have Ruby (comes with OS X) and Homebrew installed first. Install FontCustom by running:

1
2
$ brew install fontforge eot-utils ttfautohint
$ gem install fontcustom

Put your SVG files inside a folder (mysvgfiles in this example) and run fontcustom compile command:

1
$ fontcustom compile mysvgfiles

…and your custom font is generated:

Files generated with FontCustom
1
2
3
4
5
6
7
create  fontcustom
create  ./fontcustom/fontcustom-5c22c2e1aea68f865df308b2953475ff.woff
create  ./fontcustom/fontcustom-5c22c2e1aea68f865df308b2953475ff.ttf
create  ./fontcustom/fontcustom-5c22c2e1aea68f865df308b2953475ff.eot
create  ./fontcustom/fontcustom-5c22c2e1aea68f865df308b2953475ff.svg
create  fontcustom/fontcustom.css
create  fontcustom/fontcustom-ie7.css

Converting SVG to @font-face demo

For the demo I took this silhouette of man SVG image by Nevit Dilme and converted it to a font icon.

When I set CSS font-size to 1000px, I could see some rendering issues with at least desktop Chrome and iOS Safari. Small part of the man’s pipe is cut off. I have not yet had time to investigate why it happens, and if it is something that can be easily fixed.

Take a look at this JSBin demo to see some very simple examples of font icon rendering and usage: http://jsbin.com/ijifev/2/edit

Minifying SVG image files

You might not be aware that the SVG images created by image editors contain a lot of extra data that you don’t need if you want to display the image on a web page.

SVG files, especially exported from various editors, usually contains a lot of redundant and useless information such as editor metadata, comments, hidden elements, default or non-optimal values and other stuff that can be safely removed or converted without affecting SVG rendering result.

– SVGO readme

You also might not be aware that your SVG images can be minified in a bit similar way as your javascript can be. If you minify your javascript, then why wouldn’t you do the same thing for your SVG images? A great tool to do it is called SVGO.

SVGO

To install it you need to have Node.js installed. SVGO can be installed by running:

1
$ npm install svgo -g

Running the tool on the Finnish map SVG image gives quite impressive results by shaving off almost 40% of the original file size:

1
$ svgo FI-LL.svg FI-LL.min.svg
1
2
Done in 95 ms!
40.766 KiB - 38.7% = 24.99 KiB

File size savings are of course smaller when the file is gzipped:

File sizes for the SVG image unminified / minified
1
2
3
4
5
6
7
|---------|-----------|----------|
|         | Unminfied | Minified |
|---------|-----------|----------|
| Default | 41Kb      | 25Kb     |
|---------|-----------|----------|
| Gzipped | 14Kb      | 11Kb     |
|---------|-----------|----------|

The gzipped size for the optimized image is only 11Kb, so it is not much bigger than a PNG equivalent would be. Compared to the PNG version, the SVG image looks sharp on retina screens and you can scale it as much as you want.

SVGO custom configuration file

You can also use a custom configuration file with SVGO (you can use the default configuration file as a template) where you are able disable and enable various compression settings. This is helpful if you want to make sure that the minification does not create any visual differences between the original and minified images.

1
$ svgo --config my_compression_settings.yml FI-LL.svg FI-LL.min.svg

Conclusion (tl;dr)

SVG is definitely used too little on websites despite of its good support on both desktop and mobile browsers.

The lack of SVG support in Android 2.X stock browser can be polyfilled (link) by rendering SVG to HTML5 Canvas element. The performance penalty of it does not seem to be as bad as I first thought.

Canvas element’s missing toDataUrl method in Android 2.X stock browser makes it impossible to get the image out of the canvas element as a bitmap in a performant way. This means that if you don’t want to make the user wait for ages, you can only use the canvas element to show SVG images to the user.

Another workaround for Android 2.X’s lack of SVG support is to convert SVG images to @font-face icons. You can only render simple shapes or icons with it, and @font-face fonts do not work on Android 2.1 stock browser, Windows Phone 7 IE9, Opera Mini and some other browsers (link).

You should be using a minification tool like SVGO to possibly get noticeable file size savings on your SVG images.

Resources

David Bushell has written many good articles about SVG:

Chris Coyier also wrote about SVG:

SVG images can be blurry too, as Simurai points out:

Polyfilling SVG with Canvas:

Different ways of adding SVG to your page: