Improving Ember app page load times using the App Shell model

By: Marten Schilstra

In the past week I have been experimenting with the App Shell Model to make the DockYard.com website load faster. In this blog post I'll walk through which changes I made and how I've made them. Complete with some benchmarks!

Why improve page load speed?

According to Google, if loading a page takes too long, they'll just abandon it. This hurts your conversion rates. Key metrics that contribute to better conversion rates are when the browser is able to make its first paint and when the page is fully loaded.

Tools of the trade

In this section I'll introduce the tools I've used to measure the loading performance. Please take a minute to follow the links to the mentioned tools and read how they work and what they are for.

To be able to measure the performance of DockYard.com I have used Lighthouse and the Application, Network and Timeline DevTools from the Google Chrome team to see changes made a positive impact on loading speed.

Lighthouse is the successor of Google's PageSpeed tool. Lighthouse uses your Chrome DevTool to do a host of automated audits. I.E. it checks if a Service Worker is registered and that a Web App Manifest can be found. The audits I'm most interested in are the page load performance audits. These measure the first meaningful paint, the speed index and time to interactive performance.

What I'm measuring

I'm measuring the page load in two situations. The first situation is as if it's the very first time I'm visiting the website, this means no assets have been cached yet. The second situation is when I'm loading the website on a repeat visit, which means the browser had a chance to cache the assets. I'll also measure the same two scenarios using network and CPU throttling to simulate being out and about on a mobile device. Mobile simulation is done on the 'Regular 3G' network throttling setting and the 'Low end device' cpu throttling setting.

There are three key performance metrics I'm looking for in each test, first I'll look for when the first meaningful paint happens and second I'll look for the moment when the Ember.JS app has booted client side and when the Ember.JS app has done its initial render on the client side. The first meaningful paint and the initial render metric are the two metrics that according to Google contribute the most to abandonment rate.

Getting the baseline

Benchmarks have been made on a standard mid 2015 15" MacBook Pro, all tests have been done with a wired 500 megabit up/down internet connection. I have about 90ms latency towards the webserver and about 7ms to the assets server.

To start out, here are the baseline performance metrics of the current DockYard.com website. It is a Ember.js application that is served with FastBoot.

Baseline Lighthouse score

Note: Lighthouse measures without CPU slowdown and a custom network throttling setting

This initial lighthouse score shows that the first meaningful paint comes just before the time to interactive. This does mean that when the page shows up, it's almost immediately interactive, but you'll see nothing before that.

Baseline Desktop: Page load with empty cache

  • First paint: 600ms
  • App boot: 1,175ms (the dip in the CPU graph)
  • Initial render: 1,400ms

Baseline Desktop: Page load with warmed cache

  • First paint: 325ms
  • App boot: 750ms
  • Initial render: 950ms

Baseline Mobile: Page load with empty cache

  • First paint: 1,200ms
  • App boot: 8,900ms
  • Initial render: 10,200ms

Baseline Mobile: Page load with warmed cache.

  • First paint: 850ms
  • App boot: 2,850ms
  • Initial render: 4,200ms

The changes I've made

First off I've added (offline) caching by a Service Worker, using Ember Service Worker with various plugins. This did not, as expected, improve the cold boot scenarios, but did improve the scenarios with warmed cache slightly. Especially the first paint was much earlier. Results of the warmed cache scenarios are:

Service Worker only Desktop: Page load with warmed cache

  • First paint: 125ms
  • App boot: 725ms
  • Initial render: 900ms

Service Worker only Mobile: Page load with warmed cache

  • First paint: 300ms
  • App boot: 2,700ms
  • Initial render: 4,000ms

Next I wrote a small Ember CLI addon that concats both scripts (vendor.js and dockyard.js) that Ember CLI produces together. Then I proceeded to load that single JavaScript file using a `element in the head that was markedasync`. This had no significant improvement on the desktop side, but loading on mobile did improve. There is a small downside to this technique though, the two files aren't cached seperately by the browser anymore, which can increase the amount of data needed to be transferred when deploying new builds often.

Async'ed script Mobile: Page load with empty cache

  • First paint: 750ms
  • App boot: 7,950ms
  • Initial render: 9,000ms

Async'ed script Mobile: Page load with warmed cache

  • First paint: 200ms
  • App boot: 2,350ms
  • Initial render: 3,550ms

Lastly I extracted all the critical CSS for an initial render and inlined it into the `` section. Then proceeded to asynchronously load the remaining CSS using loaddCSS. This made the first paint come slightly earlier in all scenarios, but hurts the initial render by about 150ms on mobile.

Async'ed CSS Desktop: Page load with empty cache

  • First paint: 550ms
  • App boot: 1.100ms
  • Initial render: 1.325ms

Async'ed CSS Desktop: Page load with warmed cache

  • First paint: 125ms
  • App boot: 675ms
  • Initial render: 900ms

Async'ed CSS Mobile: Page load with empty cache

  • First paint: 500ms
  • App boot: 8,100ms
  • Initial render: 9,150ms

Async'ed CSS Mobile: Page load with warmed cache

  • First paint: 125ms
  • App boot: 2,500ms
  • Initial render: 3,700ms

All the stats summed up in a table

Final Lighthouse score

Note: To get the perfect 100/100 score we also added a Web App Manifest.

That's an impressive 10x speed up in first paint and almost a second and a half in time to interactive. This gives your user much more confidence in that your page is loading.

Browsers without Service Worker

You might ask: "How does this affect page loading in browsers that not yet support Service Workers?". I've tested the before and after with Safari's timeline tool. The results show no significant speed up in full page load, but actually a slight slowdown. The results do show a significant speed up in first paint time with either empty or warm cache.

After the benchmarks I've noticed that loading the service worker registration script plays a big part in the slowdown. I'd need to fiddle some more with loading that script to see if I can get rid of the slowdown.

Below are the timeline graphs for loading in Safari. Notice the big shift of the blue DOMContentLoaded line. That counts as the first paint. The red Load line is the app boot. The last green bar is the initial render.

Baseline: Page load with empty cache

  • First paint: 510ms
  • App boot: 600ms
  • Initial render: 740ms

After improvements: Page load with empty cache

  • First paint: 250ms
  • App boot: 650ms
  • Initial render: 820ms

Baseline: Page load with warm cache

  • First paint: 510ms
  • App boot: 575ms
  • Initial render: 730ms

After improvements: Page load with warm cache

  • First paint: 250ms
  • App boot: 620ms
  • Initial render: 780ms

Safari tests summarized

Conclusion

A Service Worker and a bit of techniques from the App Shell model can boost your page load times. Your app will show up on the screen earlier and be interactive quicker, which in turn can improve the conversion rates of your website.

Please try out these techniques and see for yourself if it improves page load times of your app. In any case let me know how it works out for you.

Closing: don't forget to compress your images, svg's and fonts! Those too can impact page load performance by seconds on mobile.