Engineering

Building a faster rocket: Achieving quicker app load times

Speed matters.

As large-scale software projects mature, scale, and grow complex, the effort required to ship new releases increases. With the introduction of new features and code becoming more complex, load times can slow down significantly. This in turn negatively impacts the user experience—leaving a bad impression on customers.

For our project, ServiceBridge, we faced the challenge of having significant speed performance drop on the front-end (what you experience as a user). While the back end is faster than ever, rendering that information and displaying it to the client creates a slowdown, frustrating users. Over the years the codebase has grown, accumulating large amounts of script files. Our strategy has been to bundle it in order to minimize the file size and cache for every release.

This strategy historically had a negative impact on users. With the bundle being loaded in one go (even with compression), it was a lot of data to process. While returning users no longer required networking time, every page was parsing JavaScript and executing it. All of this can be taxing on resources and time-consuming.

In today’s market, speed matters more than ever. The ServiceBridge team knew this was a problem that had to be fixed. We developed a clear plan to reduce load times and provide our users with the fastest experience possible. In this article, we’ll share insights into what we did—and what you can do—to deliver the speed and the optimal UX for your customers.

Identify the problem(s).

Often, the biggest issue is deciding where to start. What do you fix first? How complex are the problems you’re facing? What issues are plaguing users? The list of questions can go on and on. Fortunately, there are a number of tools that help identify and baseline the problem.

Audits

A good starting point is the Audits tab in Chrome Devtools. Some of the audit data varies over different runs or on different pages. Overall, you can see what’s impacting load time. It will look something like this.

Baseline data

To be able to compare if you are moving in the right direction, save this audit report for future reference.

Now, with the list of documented issues from Google, you can begin fixing your problems.

Deeper metrics

There are a number of additional metrics to help better understand how to improve load speeds efficiently such as First Contentful Paint, First Meaningful Paint, Time To Interactive, First Input Delay, DOMContentLoaded, and OnLoad. To track load speeds, browsers support APIs to measure metrics.

Some options include:

All of these metrics can be considered when looking to improve the overall user experience. Rather than fixating on a single number, the loading of a page is an experience. However, take these metrics with a grain of salt.

For example, imagine there is a fast time to interactive, but the paint is slow to load. The user is there for the text and filling in form data that is not a concern in that context. In this case, the TTI will give you a false reading. This may frustrate the user so much that they opt to leave the page. In order to remedy this, we need to know what use case we are optimizing for.

*In the case of our work at ServiceBridge, our goal was to show content as soon as possible and respond without delays so the user can feel in control while using the product.

Network

The image above is a great example of an unoptimized networking tab. While every back end response is fast, the whole experience for a user is going to be bad if the content takes a long time to render. This probably looks very confusing. Focus on the network graph and main thread activity. A good rule of thumb is to use a PRPL pattern—Push, Render, Precache, Lazy-load. Even if you don’t utilize service workers, there’s a lot to be learned here.

Webpack bundle analyzer

Another tool to identify issues when using webpack is bundle analyzer. This tool helps find ways to optimize code splitting in order to minimize network and parse time.

Monitor performance.

To get a sense of our real user performance, set up various metrics to track and decide what needs optimization. Optimization is not something that you do once and forget about—it needs attention repeatedly. To tackle optimization, automate tests.  

Here’s a snapshot of what we did for our work at ServiceBridge.

We started by setting up tracking for First Paint metrics. 

const observer = new PerformanceObserver((list) => {
    for (const entry of list.getEntries()) {
        const metricName = entry.name;
        const time = Math.round(entry.startTime + entry.duration);
        // `name` will be either 'first-paint' or 'first-contentful-paint'.
        if (metricName == 'first-paint') {
            newrelic.addPageAction('First Paint', {time});
        }
        if (metricName == 'first-contentful-paint') {
            newrelic.addPageAction('First Contentful Paint', {time});
        }
    }
});
observer.observe({ entryTypes: ['paint'] });

We used NewRelic as a browser client where we later aggregate the data and create charts and PerformanceObserver browser API for their good browser support.

Since this was (and still is) an industry standard, we no longer needed to write hacky code to implement timing logic of the metrics—everything could be handled in a way that we didn't notice any performance drop. 

The metrics in the chart above are important because if the load times are too high, users might get confused by whether or not the application is going to load.

TTI requires a polyfill. Even then, it will only work in Chrome 58+. 


// Time to interactive
!function(){if('PerformanceLongTaskTiming' in window){var g=window.__tti={e:[]};
        g.o=new PerformanceObserver(function(l){g.e=g.e.concat(l.getEntries())});
        g.o.observe({entryTypes:['longtask']})}}();

// After observer initialized
ttiPolyfill.getFirstConsistentlyInteractive({}).then((tti) => {
    newrelic.addPageAction("Time To Interactive", { time: tti });
});

Alternately, instead of coding tracking for these metrics manually, there are libraries that measure performance metrics automatically—shown here

At ServiceBridge, we measure FCP, TTI, FID.  

FCP (First Contentful Paint)  
· Fast [0, 1000ms] 
· Average (1000ms, 2500ms] 
· Slow over 2500ms 

TTI (Time to Interaction) 
· Fast under 5s 
· Slow above 5s 

FID (First Input Delay) 
· Fast [0, 50ms] 
· Average (50ms, 250ms] 
· Slow over 250ms 

We can't rely on the statistics provided by developer tools because there is so much variation in how our users interact with us. There are various devices, different browsers, network conditions, multiple pages, etc. In the histograms below, we can see a better picture of our metrics—dividing them into different speed categories and making them actionable. 

Apply optimization techniques. 

After identifying baseline problems, it’s time to get to address the issues. Here are the methods we used to improve speed. 

Preconnect, Preload 


    <link rel="preconnect" href="[https://maps.google.com](https://maps.google.com/)" crossorigin />
    <link rel="preload" href="scripts/dist/application.js" as="script" type="text/javascript" />

By preconnecting to certain domains, we got faster network calls because DNS lookup was already done and preload made our bundle priority higher. 

Async/Defer 

Third-party, non-critical scripts were deferred or made async by writing something shown below. 


    <script async type="text/javascript" src="SomePathToNonCritical"></script>
    <script defer type="text/javascript" src="SomePathToNonCritical"></script>

*You can learn more about Async/Defer here

After these simple steps, we saw a big improvement. Before the first request to back end was fired at ~4s, now we are firing it at 2.5s 

Long Running Scripts

We investigated our main thread and found the culprit. There were some long-running scripts, but why did these exist? For every script, there were various steps that the browser performed: network, parse, compile, and execute. 

A rule of thumb: Ship less code = faster parse/compile/transfer/decompressing.  

If you cache the files, you can skip networking. However, you’ll still need to parse and compile the code/files. Keep in mind that the bigger the files are, the slower things become. This is how we ended up with those long-running scripts. 

To remedy this situation, we introduced a Performance Budget

Protip #1: Include a limit on assets that load at the start so that the site doesn’t feel sluggish. If you are using webpack, you already get a feature that throws a warning if you exceed the performance budget of 244KiB. 

Protip #2: You can also turn the performance budget into an error so that you don't accidentally ruin the performance. Just add this piece of configuration in webpack. 

module.exports = {
	performance: {
		hints: "error",
	},
}

Coverage 

In the image below, you can see how much data the scripts and styles are using. When the recording mode is on, you can click around and see the numbers change. Here's where the code splitting comes into play. In this example, 78% of dead code is way too much. Don't forget that every single byte of this code must be sent, parsed, and executed—meaning everything can be super slow. 

Bundle splitting 

To improve our coverage score and beat the long-running scripts (ultimately reducing the required assets for the existing page), use dynamic imports to split bundles by route. 

import urls from '../utils/urls';

if (urls.is('')) {
  import(/*webpackChunkName: "dashboard" */'./dashboard');
}

By doing so, we found that the monolith broke up into pieces and our metrics improved. Awesome! We did use dynamic imports to split bundles by route (not code splitting yet). But we're not done yet. 

Webpack provided vendor splitting and we had to split up some components by using the same dynamic import feature. Another fix we needed to address was that the picture looked more granular. Keep in mind, too much granularity can be a bad thing. If too many requests need to be made, these small components could result in the same sluggish performance. 

To perform well, look for an optimal amount of bundles being downloaded at the same time before the applications start throttling the network. In our experience, 5 bundles at most are optimal. 

The result after all this optimization? After a few rounds of improvements and releases, we established better monitoring capabilities and observed the following improvements: 

· FCP (First Contentful Paint) on average improved by 43%. 
· Through code splitting, JS size decreased by 32%. 
· Assets are now asynchronous and do not block the main thread to the same extent. 

Lessons learned 

Fast performance has a far reach for your product. It is an expectation of today’s users. People want their integrations with technology to be fast and easy. It impacts your bottom line. Your business needs to prioritize their customer’s needs to succeed. Having a strong, fast mobile-first experience even improves lead generation, since Google ranks mobile-first apps higher in their search results.  

While we're still a long way from being perfect, having fast load times is important. Keep user’s needs front and center. For ServiceBridge, the ultimate goal for our team is always to deliver a satisfying experience for the field technicians and office workers that use our product every day. In order to stay relevant, our team continually releases new features while balancing that expectation with keeping the UX with our product fast. 

For further reading:

Amp Up App Performance with RAIL 

Why Performance Matters