Background
One of the main pieces of profiling an app's performance is measuring how long it takes to route from one view to another *(more comments at end). If you can track over time your app's view-to-view rendering times, then you get the powerful, God-like ability to see at-a-glance when any of your routes have slowed down unexpectedly.
What are you measuring?
When you're measuring the time it takes to route from one view to another, what are you really measuring? While we could spend an afternoon discussing what the most important pieces to measure are, I am going to take another angle. Let me share the user story that I needed to solve.
As a user, I want to measure the time it takes from when I start to navigate until the next page is fully rendered.
So once the navigation starts, the user wants to know how long it takes until the next view is fully rendered. Great! Now that I know what I want to measure, how do I do this in Angular? Let's talk about how I detect these two distinct moments: Navigation Start and View Fully Rendered.
Detecting "Navigation Start"
All frontend frameworks that have a router make detecting the beginning of the navigation easy. Angular is no exception. In Angular, we can use the Router
for this. The following is a code block that we can run to grab only NavigationStart
events, and then track when that happens.
@Injectable({providedIn: 'root'})
export class PerfService {
navStart$ = this.router.events.pipe(
filter( event => event instanceof NavigationStart),
startWith(null), // Start with something, because the app doesn't fire this on appload, only on subsequent route changes
tap(event => /* Place code to track NavigationStart here */),
).subscribe();
constructor(private router: Router) {}
}
In the above code, I am successfully detecting the start of navigation. Easy!
Now let's look at what we need to do when the next view is fully rendered.
Detecting "View Fully Rendered"
Angular doesn't provide a way for us to intrinsically know that a view has fully rendered. There isn't a lifecycle hook in Angular or React that says "All Things Accounted For, I am Fully Rendered!". The closest thing we have in Angular is AfterViewInit
, which only tells you that the initial view of the component has been rendered. Any requests to the server will blow this away.
So now what?
A Too Simple Approach
The simplest approach to detecting when a view is fully rendered is to look at its code, and once all of the data has been retrieved and set onto your component, you can then say that this view is fully rendered.
But think about that...
You have to modify every single component in your app to detect and report its Fully Rendered
event. That means three things:
- You have to modify every top-level component in the app and teach it to report this event.
- You have to create an audit so that no one on your team has forgotten to report this event on any future routes.
- The code that reports this
Fully Rendered
action is spread throughout your entire application. 🤢
This was not a good enough solution. So let me tell you what I did. I wanted something that didn't require me to modify every component manually and distribute the detection code across the app. I wanted a federated approach that allowed all of the detection code to be collected into one place. So how can I do that?
An Accurate Approach to Fully Rendered
zonejs
to the rescue! One great things about working with Angular is that you already have zonejs
in your app. And for this problem, zonejs
is PERFECT!!
When anything noteworthy happens in an Angular app, the NgZone
will tell you that it is not stable. And once all of the noteworthy things have finished, it will tell you that it is stable again. So, once the NavigationStart
fires, you can ask the NgZone
to tell you once all of the noteworthy things have finished happening. This way you can distinguish between your app actively rendering the next view and when the app is done retrieving data and rendering the next view. SO COOL!
Anything noteworthy in Angular will create whats called a macrotask in the Angular zone. Any time we fetch new data or the component is deciding if it needs to re-render, a macrotask is created in the zone. So we can ask the zone if it has any macrotasks pending.
Note: I didn't use zone.isStable
because sometimes the zone.isStable
will return true, even when we have pending macrotasks. So it's better to look at the pending macrotasks.
Once the NavitationStart
fires, you can use some code like this:
// Once NavigationStart has fired, start checking regularly until there are
// no more pending macrotasks in the zone
// Have to run this outside of the zone, because the call to interval
// is itself a macrotask. Running that interval
inside the zone will
// prevent the macrotasks from ever reaching zero. Running this outside
// of Angular will not track this interval
as a macrotask. IMPORTANT!!
this.zone.runOutsideAngular(() => {
// Check very regularly to see if the pending macrotasks have all cleared
interval(10)
.pipe(
startWith(0), // So that we don't initially wait
// To prevent a memory leak on two closely times route changes, take until the next nav start
takeUntil(this.navigationStart$),
// Turn the interval number into the current state of the zone
map(() => !this.zone.hasPendingMacrotasks),
// Don't emit until the zone state actually flips from false
to true
distinctUntilChanged(),
// Filter out unstable event. Only emit once the state is stable again
filter(stateStable => stateStable === true),
// Complete the observable after it emits the first result
take(1),
tap(stateStable => {
// FULLY RENDERED!!!!
// Add code here to report Fully Rendered
})
).subscribe();
});
Voalá
At this point, we have detected both the NavigationStart
as well as the Fully Rendered
moments. We can now use something like PerfumeJS
to begin measuring these times. Once PerfumeJS
measures the times, we need to stick those into our analytics database so that we can begin to track these over time.
Viewing this data over time on a per-route basis allows you to understand you apps in very meaningful ways. If an API ever slows down, the views affected will load slower, and this test will tell you. If someone checked in some bad code that is killing the app performance, this will tell you.
I invite everyone to begin checking out their apps performance and using approaches like this to do that.
Afterthought
In your app, if you have any long running setInterval
calls then it is possible that the macrotasks
never settle in your app. If this method doesn't work for you, then you have other issues in your app. It is very easy to find a third-party component or library that will have a setInterval
that will prevent your app's zone from ever settle back to zero pending macrotasks. How to find and fix these issues is the topic of another blogpost, another day. If anyone is in this situation and would like to chat about getting it fixed, please DM me on twitter :)
Extra Thoughts
* Regarding the most important things to measure on your apps performance... there are a lot of things to measure. Things like time to first byte or time to first meaningful content. This post is not to declare that this is the most important piece of frontend performance measuring. It is, however, something that should matter when you look at how your app is performing.
Top comments (5)
Interesting, there is a problem that if you assign prop which is used in a template - it takes time for respective elements to be rendered by Angular. Is it possible that zone is stable but angular still do some operations with DOM?
No. Actions in the DOM are synchronous and would happen in the same even cycle. Any promises or setTimeouts that would something on the next event cycle would cause the zone to destabilize.
Great article... I was searching for something that "globally" could let me know if the new route/page navigation was fully loaded (html binds/http requests/etc), but I'm facing some issues...
From where comes the this.navigationStart$ on the takeUntil()?
I was trying to pass the navStart$ subscription but I get an error:
"Uncaught TypeError: You provided an invalid object where a stream was expected. You can provide an Observable, Promise, Array, or Iterable"
If I remove the takeUntil() it seems to be working but in the article it refers that may cause memory leaks... can someone help me?
It comes from the router events stream. You don't have to pass the subscription, but the observable itself.
I have try but it's look like method not acurately work everytime. So I have wrote another function, with the help of: github.com/angular/angular/blob/b6...
isStable = new Observable((observer: Observer) => {
let stableSub: Subscription;
this._zone.runOutsideAngular(() => {
stableSub = this._zone.onStable.subscribe(() => {
NgZone.assertNotInAngularZone();
if (!this._stable && !this._zone.hasPendingMacrotasks &&
!this._zone.hasPendingMicrotasks) {
this._stable = true;
observer.next(true);
}
});
});
const unstableSub: Subscription = this._zone.onUnstable.subscribe(() => {
NgZone.assertInAngularZone();
if (this._stable) {
this._stable = false;
this._zone.runOutsideAngular(() => {
observer.next(false);
});
}
});
return () => {
stableSub.unsubscribe();
unstableSub.unsubscribe();
};
});
using:
this.isStable.pipe(debounceTime(1000)).subscribe(val => {
console.log('completely stable');
});