Traditional RUM tools focus on page load times as the ultimate measure of website performance. However, this is an inaccurate measurement for Progressive Web Apps (PWAs) and Single Page Applications (SPAs). In PWAs and SPAs nearly all of the navigation takes place entirely on the client, meaning that traditional RUM measures of page load time are either inaccurate or only measure a fraction of the user’s perception of speed.

Even for sites that are not PWAs or SPAs it’s critically important to measure  performance of navigations that occur after the user lands on your site. These navigations represent the bulk of the user experience, and thus the area most important to optimize.

Unfortunately, measuring client-side navigation speed isn’t straightforward. Unlike a traditional page load, which can be timed by DOM events, or the built-in performance available in window.timing.performance, the start and end time of a client-side navigation event depends on how your site responds to a history change, retrieves data from the server, and repaints the page. Maybe that’s why few, if any, RUM tools reliably capture client-side navigation performance. RUM tools are also expensive at scale…

Enter Firebase Performance Monitoring. It’s free, and it automatically tracks key metrics like First Contentful Paint and First Input Delay on initial page load, and makes it really easy to implement client-side navigation performance using custom timings. This is nothing less than a game changer in the world of RUM.

Measuring client-side navigation performance

Here’s how we use Firebase Performance Monitoring to track the time it takes to navigate to a new page on the client in React Storefront, Moovweb’s open source React framework for building eCommerce progressive web apps and AMP pages.

Install dependencies

First, we followed this guide to get Firebase installed on our test app. There were two libraries to install from NPM:

npm i --save firebase first-input-delay

Create a React component

React Storefront uses mobx and mobx-state-tree for managing state. We create our component as a class and inject the React app’s loading state and the router as props:

import React, { Component } from 'react'
import Helmet from 'react-helmet'
import { inject } from 'mobx-react'
import fid from '!raw-loader!first-input-delay' // eslint-disable-line import/no-webpack-loader-syntax
 
@inject(({ app, router }) => ({ loading: app.loading, router }))
export default class Firebase extends Component {
 render() {
   return (
     <Helmet>
       <script>{fid}</script>
     </Helmet>
   )
 }
}

Here we used react-helmet to add the first-input-delay polyfill to the document head, so that Firebase can capture first input delay as a timing metric.

Initialize Firebase

Next, we initialized Firebase in componentDidMount. We initialized Firebase here in componentDidMount, rather than in the constructor, because progressive web apps built with the React Storefront framework are universal (also known as isomorphic), which means that the app can run on the server as part of a server-side rendering process (SSR). Attempting to initialize Firebase on the server will fail because it expects the window object to be present.

As an aside, this is a common issue with server side rendering, and fixing this across all your dependencies makes it difficult to add SSR to an existing React project. This is one reason why it helps to have a framework like React Storefront that adds server-side rendering to your project from the start.

 componentDidMount() {
   // These libraries expect a browser environment, so we load them here
   // rather than as imports so our component doesn't throw an error on the server.
   const firebase = require('firebase/app')
   require('firebase/performance')
 
   // All of these are available from the settings page in the Firebase developer portal.
   // Settings => General => Your Apps => Firebase SDK Snippet => Config
   const firebaseConfig = {
     apiKey: 'XXX',
     authDomain: 'XXX',
     databaseURL: 'XXX',
     projectId: 'XXX',
     storageBucket: 'XXX',
     messagingSenderId: 'XXX',
     appId: 'XXX'
   }
 
   // Initialize Firebase Performance Monitoring
   this.perf = firebase.initializeApp(firebaseConfig).performance()
 
   // Start a trace each time client-side navigation occurs
   this.props.router.on('before', this.startTrace)
 }

Start the trace when navigation begins

Next, we implemented startTrace, which starts a performance trace each time a client-side navigation begins.

startTrace = () => {
 // Create a custom trace called navigation
 this.trace = this.perf.trace('navigation')
  // Here we capture the URL the user is navigating to
 // Firebase allows you to add any attributes you want and then filter by them when viewing
 // the trace data in the developer portal.
 this.trace.putAttribute('location', window.location.pathname + window.location.search)
  // capture the start time
 this.trace.start()
}

End the trace when the page renders

But, how do we know when a client-side navigation has finished? This depends on how your progressive web app is implemented. The initial inclination is to use the router’s after event to end the trace. This seems logical, since we’re using the router’s before event to start the trace. However, the after event fires as soon as data is retrieved, but before a page is actually rendered. It doesn’t adequately capture how users perceive performance.

Thankfully, React Storefront’s app state tree contains a loading property that is set to true whenever data is being fetched from the server. By ending the trace in componentDidUpdate when loading is changed to false, we know that we’ve accurately captured both the time spent getting data from the server and the time spent rendering it on the client, thus measuring the perceived performance of the React app.

componentDidUpdate(oldProps) {
 if (oldProps.loading && !this.props.loading && this.trace) {
   // end the trace and send the data to firebase when loading switches
   // from true => false
   this.trace.stop()
   delete this.trace
 }
}

Push to production

Once we implemented our custom trace for client-side navigation, all that was left was to push it to production. With the Moovweb XDN infrastructure every time changes are pushed to GitHub a new version of the site is automatically deployed, so deploying was simply a git push. For our initial implementation, we installed Firebase RUM on the React Storefront documentation site.

Viewing the results in Firebase

One of the few drawbacks of Firebase Performance Monitoring is that it takes up to 12 hours for performance data to show up in the developer portal. I’d love to see them eliminate that latency, even if only for traffic from localhost. That would certainly make the development process easier by providing immediate feedback that everything is set up correctly.

We checked in on Firebase the following day and saw the results begin to roll in. Here’s a sample of the performance data, including our custom navigation trace:

Here’s what you will see when you drill down on the navigation trace:

Understanding the RUM data

There are two figures here that are more important than the rest: First Contentful Paint and Client-Side Navigation.

First Contentful Paint

First Contentful Paint is the time the user spends waiting for the website to initially load. If you’re replacing a well-tuned, traditional website with a PWA/SPA, you may find it difficult to get a lower First Contentful Paint from the PWA/SPA. In fact, it’s likely impossible to improve upon a traditional website with a PWA/SPA without the kind of server-side rendering React Storefront gives you and the caching that Moovweb XDN provides.

First Contentful Paint depends on the speed of your backend infrastructure, including your CDN, application servers, APIs, etc…  The Moovweb XDN infrastructure, on which we’re running this PWA, gives us everything we need to optimize for this metric: greater control over caching across the stack, a large number of global points of presence (POPs), and a highly scalable serverless PaaS infrastructure. But, where PWAs really shine is in client-side navigation: all of the pages that users view after they initially landing on your site.

Client-side navigation

You can see in the screens above that our custom trace has allowed us to measure the median client-side navigation duration, which was 275 ms. This is a huge improvement over traditional websites where every navigation results in a full page reload. One of the things Firebase shows you that’s an absolute must-have is the histogram or shape of the distribution:

Here we can see that the majority of values are below and close to the median, with a long tail up to 0.92s. It’s the long tail that’s often overlooked. If that long tail were much longer, say 10s, and affected even 5% of your total traffic, that would represent 5% of users experiencing a near-broken experience. Fortunately, our long tail only extends to just under a second, so our worst case is actually better than the best case of most other sites.

Key Takeaways

React PWAs and SPAs now have a compelling offering for accurately measuring client-side navigations (i.e. browsing speed). This is critical because most synthetic performance tools (such as Lighthouse) typically measure only the first-page load, even though browsing makes up the bulk of the user experience.

Consider this: the average eCommerce session is just over three minutes long and covers six pages, each taking almost seven seconds to load. That means for 20% of the session users are waiting. Shaving a second off of the first-page load doesn't drop the total wait time much, but using a React PWA to optimize those six pages to subsecond loads like the 300ms we demonstrated above brings the total wait time down dramatically to a mere 5%

No matter what RUM tool you end up choosing for your site, make sure it shows you the full histogram or distribution of your performance. This way you’ll know both the median, which represents the average user experience, and the long tail, which describes the worst experience users are having on your site. Sadly, it’s the latter that’s likely to end up on social media and have an overrepresented affect on your conversion rate and brand perception.