Speed — Maureen Barlin (CC BY-NC-ND 2.0)

Optimising the performance of an Ionic application — Part 2

Journeying from Ionic 2 (beta) to Ionic 3

Introduction

In Part 1 of this series, we looked at some simple optimisations that we could apply to the Odecee Tech Radar app. Those optimisations were not Ionic-specific, but they provided the biggest benefit-for-effort.

In this article we will look at some Ionic-specific and some other general optimisations which we made to the app, and how they improved the app’s performance.

Starting Point

After the optimisations we made in Part 1, our network performance looked like this over a wi-fi network:

Over a wi-fi network, this is not great. On a mobile device and a slower network, it could easily take more than 20 seconds to load for the first time. Looking at the above graph, there is one obvious culprit — app.bundle.js is 1.3 megabytes. This is simply too large. (Additionally, you may notice that vendor.bundle.js is suspiciously small. This appears to be a build-config problem).

We needed a way to make these files smaller. Looking inside app.bundle.js file, we immediately noticed that there was no minification being performed on the file. Great — we can fix that. But there was another technique we could use if we could migrate to the full version of Ionic 2 (or Ionic 3 as-of the time we commenced this optimisation process): Angular’s Ahead-of-Time (AOT) compiler. Using AOT compilation should reduce both the byte-size and startup-time of the app. So we took the plunge…

Upgrading to Ionic 3

Before the upgrade

One of the biggest issues the development team faced when building Tech Radar was that they were working with a beta-version of both Angular 2 AND Ionic 2 (from July-Dec 2016). There was an enormous number of bugs which they found which were known-bugs, plus a few bugs which were not-so-well-known. The team did an amazing job producing a working application despite building on top of two unstable frameworks.

A decision we made early-on was not to invest in the constantly-changing unit-testing approaches for Angular 2. Those who tried to keep up with Angular 2 during this time faced many breaking changes with each beta & release candidate. We wanted to avoid this churn. So we invested in writing system tests (browser tests) with the newly released CodeceptJS instead of unit tests.

Upgrading

After an early-but-failed-attempt to upgrade to Ionic 2 in November 2016, the team re-attempted the upgrade to Ionic 3 in June 2017. As well as framework changes, we were also dealing with Angular-platform changes. Since Angular 4 had been around for a few months by this time, there was much better documentation available which allowed the upgrade to take place successfully.

The main reason for wanting to upgrade was that the newer tooling provided:

  • AOT compilation (for smaller runtime code)
  • Webpack 3 (for tree-shaking & better module bundling)
  • IonicPage, a new API that provides both deep-linking functionality (for inviting people to a specific group or radar) and code-splitting (allowing the initial bundle to be slightly smaller)
  • Service worker support is built-in (although we ended-up using WorkBox instead)
  • Better error logging and debugging support

The approach we took to successfully upgrade the application was to generate a new project, then bring the old code into the new project. That way we could ensure that we had an initially-working setup and could fix the old-code as it was integrated into the new project.

Reducing bundle size (minification)

Before using minification, the size of the main JavaScript files was: 455,635 + 5,093,611= 5,549,246 bytes

After minification (using Webpack 3): 334,538 + 1,087,191 = 1,421,729 bytes.

That’s a 74.4% reduction in the amount of JavaScript being served over a network! With GZip applied, the total size of the 2 largest files reduced to 397,500 bytes! That’s over 90% smaller than the original size!!!

I know, right?!

Code Splitting

A happy side-effect of getting deep-links to work was that each page that had a deep link was also lazily-loaded! That’s how Ionic’s IonicPage class works; it creates a split-point which WebPack sees and creates a JS bundle containing just the code needed for that particular page. The effect on performance was minimal though.

Reducing the colours in $colors

After upgrading to Ionic 3, we noticed something strange. Our CSS file had grown from around 500KB (which is still pretty large) to 2.6MB. <Insert “I know, right?!” clip again>. Suffice to say, this is a completely ridiculous amount of CSS. I don’t care if you are deploying your app to an AppStore, this amount of CSS is a code smell which required investigation.

It turns out this was known Ionic behaviour. A bug? Let’s just say the Ionic team are working to improve this.

We reduced the size of the CSS file to 369KB by simply changing variables.scssfrom this:

$colors: (
primary: color($tr-colors, brand-primary),
secondary: ...
... // 20 other colours
);

…to simply this:

$colors: (
primary: color($tr-colors, brand-primary),
);

So keep an eye on the number of colours in your $colors map.

Adding a Service Worker

A service worker is a JavaScript file which sets up a client-side proxy server between your browser, a cache, and the network.

Courtesy of Google Slides

The primary benefit from using a service worker is that it can cache your static assets (your “app shell”), which means the browser can load your application faster (from the cache rather than from the network). But it is also a key ingredient of Progressive Web Applications — a set of technologies which allow features like offline-mode (allowing you to use the app when there is no network or limited network), notifications and installation of a web app directly onto a mobile device.

Ionic has some built-in support for implementing a service worker, but we found that Workbox was a better (& newer) tool which simplified our implementation. For our app, we wanted to:

  • Pre-cache all the static files (every file in the www directory)
  • Go to the network-first for requests to our API, and then cache the responses (for a future offline mode feature)

(We also added rules to cache some non-local fonts plus the ionicons.woff2?v... font, as Ionic appends the version number to the end of it in the CSS reference to the file 😒).

Below is an example of our service-worker.js:

Service worker example with WorkBox

Test Optimisation

One of the downsides of (browser-based) system-tests is that they take a long time to run. On a 16GB MacBook Pro, 95 tests took around 8mins to run (about 5sec per test). So we investigated CodeceptJS to see if it could run tests in parallel. Turns out it can :)

The tricky part with running things in parallel is discovering:

  • how many tests can be ran in parallel
  • how many tests can be run in parallel consistently (stability)
  • will this work on continuous integration servers

To mitigate the risk of CI not working, we kept the CodeceptJS config that allowed the tests to run in sequence, so that if nothing works in parallel, we still can run the tests. As it turns it, the most important factor in getting the tests to work on CI was memory. The more memory, the less likely that Chrome would crash.

To determine how many tests could be run in parallel, we started measuring the time taken to run different numbers-of test-suites:

System Test Test-Suite Times Chart
All tests were run using headless Chrome 60. Each test-suite contained 1-to-10 tests; so each suite was not directly-comparable to the next suite. YMMV.

When running 10 test-suites in parallel, the CPU was maxing out. When running 15 suites in-parallel, we started to get test instability. So based on these numbers, we opted to group the test-suites into 5 groups and see if we could find a stable-yet-performant balance. After a little trial-and-error, we found a combination of test suites that took no longer than 2mins 15sec to run (1.42sec per test), a time reduction of almost 72%.

Final Results

Let’s look at the best part of this article, the final results. Below are some graphs illustrating how the performance-profile of the application changed after applying each set of optimisations. The data is presented in the following order (left-to-right):

  • Pre-Opt — pre-optimised (initial) version of Tech Radar
  • Post-opt Part 1 — the optimisations applied to Tech Radar after Part 1
  • Post-opt Part 2 — First Load — all the optimisations applied, measured when the app is visited for the first time
  • Post-opt Part 2 — Subsequent Load — all optimisations applied, measured on subsequent visits to the app
Network Requests Chart

This graph is interesting because the “Post-opt Part 2 — First Load” optimisations actually increased the number of requests! The additional requests were due to code-splitting (one extra file for the first page) and the service worker (which also loads a Workbox runtime file) — 3 extra files.

For subsequent loads (the last column), there should be zero network requests. But there’s actually 6. Because Chrome specifically doesn’t route favicon requests through the service worker. Those 6 requests comprise the 37kB downloaded (see next graph) every time we load the application even when using a service worker.


First Page Size Chart

This chart illustrates three important points about optimisation:

  1. Optimising the performance of an application has significant benefits to users and to the companies hosting the applications. Less data usage leads to lower cost of ownership and better user experience. Win-win.
  2. Diminishing-returns: the benefits of optimisation diminish as more optimisations are applied.
  3. Not all optimisations are equal. In general, try to do the biggest-benefit optimisations first, as we did with the set of optimisations in Part 1. Arguably the service worker optimisation is the biggest optimisation, but I would counter that it relies on people fully-loading the application at-least once. If the app takes 30 seconds to load (due to huge JavaScript or CSS files on a slower network), the service worker will never activate if the user leaves (and never comes back).

Load Events Chart
The data for this graph comes from an average of 5 test runs (7 test runs were performed, then the min & max outliers were ignored. The pre-opt data is the exception — only one measurement was taken — as the old version of the app was removed before this article was written).

These are wonderful numbers! 😁 They illustrate the benefits of minification (Part 1 versus Part 2) and service workers (first load versus subsequent load). However, the impact of the service worker is hardly noticeable on such a fast network.

If we run the tests again over a slower network to measure the load events (using the same test methodology), we can see more clearly how the service worker improves performance:

Load Events on 3G Networks Chart

The benefit from using service workers increases as the network speed decreases.


What’s Next?

There are still more optimisations that we could perform. For example, we could change the first page to not-require Angular so that the large JavaScript files could be loaded in the background after the first page is rendered. We could also look at identifying the critical-path-CSS and inlining that into the HTML page(using something like critical by Addy Osmani).

What would *you* recommend to further improve the performance of the app?

The production version of the app is not yet ready as of the time of writing. But when it is I’ll update this post.

One clap, two clap, three clap, forty?

By clapping more or less, you can signal to us which stories really stand out.