How to Improve App Performance
As a Developer Advocate, a common question or comment I hear from folks is, “My app is slow. How can I fix it?” While this may seem like a clear or concise question, it’s quite complex and can lead to a lot of back and forth about the app in question. Given that I’ve been helping folks for a while now, I wanted to break down some common issues around slow apps and explain the steps you can take to debug potential issues in your app.
Understanding The Source
The first step to solving a problem is to understand where the issue is coming from. With a slow app, we first need to figure out if it’s a startup issue or a runtime issue. Each issue will require a different debugging process, which can lead you to two very different places.
With startup performance, we’re typically looking at a few key metrics:
- Time for resources to download
- Time for your app to show content (FCP, LCP)
- Time for your app to become interactive (TTI)
There are more metrics that can be inspected, but starting with these three should at least get you on the right path.
Runtime performance is arguably more challenging to measure as it requires runtime analysis, which can be complicated if you have never done it before. But in general, the metrics we evaluate are:
- Code execution time
- Complex layouts/layout thrashing
- Animation of non-performant properties
These metrics are a bit more fuzzy as they can vary from project to project.
Inspecting Something In The Wild
With these metrics in mind, let’s look at an example of a real app that is out in the wild, Star Track. Star Track is an app I’ve been building for a few years now and has been my test case for performance testing, implementing Ionic updates, and exploring various libraries. The app is complex enough to be considered beyond “Hello World,” but not so complex that you’re struggling to understand how it works. The source for Star Track can be found here.
Starting with Startup
Starting off, I have Star Track built in production mode and deployed through Firebase hosting. We want to test the initial load time of our app to see how long it takes for the app to load, show meaningful content, and become interactive.
For Chromium-based browsers (Edge, Brave, and Chrome), we can make use of Lighthouse, a tool that not only tests the metrics mentioned above, but also provides other performance metrics and suggestions for improvement. In my experience, however, Lighthouse can return inconsistent results as the heuristics used are constantly changing. While Lighthouse can be a good guide, it shouldn’t be used as your only source of truth. Other options include Webpage Test which can run a similar set of tests multiple times to get a more “real” set of results.
If you want to test manually, simply open the network tab of your DevTools, disable cache, disable service worker, reload the page, and watch the network requests come in. By keeping tabs on the requests made, be it JavaScript, images, stylesheets, etc., you can make some very general assumptions of your app. Generally speaking, if you’re seeing a lot of requests, your startup time is probably going to be slower. There’s obviously nuance in that statement, as there could be many small requests which don’t take a lot of time.
Overall, your best bet is to have testing be part of your CI workflow with tools like Lighthouse CI. That way, as you develop your app, you avoid the risk of new features slowing down your app.
Something to take away from these tests is that the conditions we’re setting are not meant to replicate the real world. Consider these tests as exaggerations of what your users might face.
Running Fast at Runtime
Now, even if your app starts fast, it’s not always a guarantee that the app will stay fast. It’s possible to take an app, navigate to a new route, and hit a huge performance bottleneck based on what you are rendering or how you are rendering. Runtime performance can take what was a fast-starting app and slow it down, making users feel even more frustrated. For example, let’s take this snippet:
export AlbumPage {
public album = [];
constructor(
private api: ApiService,
private route: ActivatedRoute,
) {
const id = this.route.snapshot.params.id;
this.api.fetchAlbum(id).pipe(res => this.album = res);
}
}
This is a fairly typical example of getting some data when you enter a new route/page, so what could be the issue with this? Well, we need to consider the order of operations when we navigate in an app. A simplified overview of what happens is:
- User taps a link to a new route
- The router prepares to navigate
- The new component is created
- The component is animated into view
- The component becomes “active”
Between the component being created and becoming active, when the component is animating in, we should be keeping the DOM structure of the new page frozen. By keeping the structure the same as the animation plays, we remove any potential for jank or slowness in our app. If we attempt to animate a component and modify the DOM structure of that component at the same time, the browser now needs to re-layout the entire document, which is fairly expensive to do.
If that is the issue, how can we solve it? By simply moving our fetch request to happen inside an Ionic Lifecycle event, we can coordinate when DOM updates should happen. For this, we’ll use the ionViewDidEnter
event, which will defer making the request until after the animation has completed.
export class AlbumPage {
public album = [];
constructor(
private api: ApiService,
private route: ActivatedRoute,
) {}
ionViewDidEnter(){
const id = this.route.snapshot.params.id;
this.api.fetchAlbum(id).pipe(res => this.album = res);
}
}
Now, when the component is done animating, we’ll make our requests, and then update the DOM. This keeps our component in a stable state until it is ready for any changes.
Sharing Data Throughout Your App
For most cases, requesting data on every component might not be the best solution. With the example above, the data between the two pages isn’t identical, so for this we need to request it. But if your data is identical, it can be more performant to just pass that data along.
If this is your case, what you can do is create a dedicated service (in Angular) that will let you set a class property and set its value to be the data we want to share.
@Injectable({
providedIn: 'root'
})
export class StateService {
public value;
}
With this, when we want to navigate and share the data, we can set the value
property before we navigate:
export class BrowsePage {
constructor(
private stateService: StateService,
private router: Router
){}
navigate(album){
this.stateService.value = album;
this.router.navigate(['/', 'album', album.id])
}
}
Then in the AlbumPage
we can read it when the page is ready.
export class AlbumPage {
public album = [];
constructor(private stateService: StateService ){}
ionViewDidEnter(){
this.album = this.stateService.value;
}
}
Now, this is a pretty trivial example, but the concept can be applied to any situation. The main takeaway is that if we have the data already in memory, we should remove any extra requests and just use it. This will reduce the overall time it takes for users to navigate and get something visible in the UI.
Digging Further
So far, these two examples are really just the tip of the performance iceberg. There’s so much more that could be explored here, from how complex of a DOM structure is being shipped, to the actual code developers write.
Performance is a never-ending topic, so we could spend days talking about it. I want to hear from you about some common performance issues you face. Are they runtime or startup related? Let me know where you are having trouble, and I’ll dive into it! Cheers!