Follow me on Twitter at @tim_deschryver | Originally published on timdeschryver.dev.
To give a little background, at work we're creating an application to schedule the daily rounds of caregivers.
This is done in a one-week calendar view for multiple caregivers, typically between 20 and 50 caregivers are being scheduled at the same time.
In the calendar view, we have a row for each caregiver, and there's are columns that represent each day of the week.
If everything is loaded, we speak about more than 1.500 items in total on the calendar.
Besides the calendar, there are several side panes for convenient utility views, for example, a view for items that still need to be scheduled that week, or conflicting appointments.
Technically, this is an Angular application and it's using NgRx.
Loading the main calendar view happens incrementally, there are different calendar items (the main ones being appointments and absences) and they are all fetched in parallel.
Once the most important data is loaded, the side panes are loaded, and the view will update accordingly.
We also load the schedule for the next week, to provide a smooth week transition.
There's one NgRx selector that combines multiple slices of the state for this calendar view, so when there's a data change the whole view gets updated. This makes it a joy to work with, hurrah for push-based architectures!
It's here, that at a later phase during development when all different items were loaded that we started to see performance issues. Nothing big in general but there were small hiccups, these were sensible while working on the schedules. The mouse would lag behind, and popups were opening slow.
In this article, we'll take a look at the changes we made to keep the view snappy.
Root cause
After a few console.log
statements inside the OnChanges
lifecycle hook of the main components, we noticed that most of the components were rendering too many times. This had a ripple effect, and thus some of the heavier functions were executed too many times. Our main job was to lower the number of change detection cycles, by a lot.
We already had the ChangeDetectionStrategy
of all of our components to ChangeDetectionStrategy.OnPush
, and we're already using pure pipes in multiple places of our application.
These good practices took us far, but not far enough later on in the development phase.
Solutions
- @HostListener runs the change detection cycle
- Do heavy lifting up front (and only once)
- Pure pipes to prevent method calls
- trackBy to decrease the number of DOM mutations
- Virtual scrolling for large lists
- Referential checks (NgRx)
- Preventing selector executions (NgRx)
- Detach components from the change detection
@HostListener runs a new change detection cycle
This one, I did not know of.
The calendar component works with different shortcuts, and we used the @HostListener
decorator to react to keydown
events.
When the decorator emits a new event it will run the change detection cycle of the component.
Even if the pressed key isn't handled, nor isn't modifying the component's state.
To fix this, we switched to using the RxJS fromEvent
method to detect when a key was pressed.
The handled events are dispatched to the NgRx store, to modify the state.
With this change, the view only updates when the state inside the NgRx Store changes, in comparison to every keydown
event.
@HostListener('document:keydown', ['$event'])
handleKeyboardEvent(event: KeyboardEvent) {
const events = {
'ArrowLeft': this.previousWeek,
'ArrowRight': this.nextWeek,
}
const event = events[event.key]
if (event) {
event();
}
}
ngAfterViewInit() {
fromEvent(document, 'keydown')
.pipe(
map((event: KeyboardEvent) => {
const events = {
'ArrowLeft': this.previousWeek,
'ArrowRight': this.nextWeek
}
return events[event.key]
}),
filter(Boolean),
tap(evt => evt()),
takeUntil(this.destroy)
)
.subscribe();
}
Do heavy lifting upfront (and only once)
The initial NgRx selector returned a list of caregivers and a list of appointments.
The calendar component has a loop over this list of caregivers. And inside the loop, we had a second loop over the days of the current week. To get the appointments of the caregiver for the given days, we used the getCaregiverSchedule
method. The method filters out the appointments for the current employee, and the current day.
<div class="row" *ngFor="let caregiver of calendar.caregivers">
<caregiver-detail [caregiver]="caregiver"></caregiver-detail>
<caregiver-day-appointments
*ngFor="let day of days"
[scheduleItems]="getCaregiverSchedule(caregiver.id, day)"
></caregiver-day-appointments>
</div>
getCaregiverSchedule(caregiverId: number, date: Date) {
return this.calendar.scheduleItems.filter(
item => item.caregiverId === caregiverId && dateEquals(item.date, date)
);
}
For one caregiver, the getCaregiverSchedule
method was called 7 times. If there were 20 caregivers on the screen, the method was executed 140 times.
It was this method that was having difficulties because it contained the list of all the appointments from all the caregivers, and had to loop through the whole list of appointments, for every caregiver, for every day. At first sight, this does not look too bad. But... this triggers a change detection cycle for the child component because the input changes. To make it worse, this gets repeated whenever the Angular change detection cycle runs for this component.
We noticed that this method was easily been called around 2.000 times in a matter of seconds, repeatedly.
It was also the main cause to change the HostListener because it didn't help that this was executed on every keystroke.
To solve this, we moved the filter logic to the NgRx selector. Where it should live.
Instead of 2 separate lists, we modeled the data to serve the view.
We removed the appointments list and moved it as a property to the caregiver.
By doing this, the filter logic for the caregivers is only executed once, when the selectors emit a new output.
Because the reference to the caregivers and their appointments remain the same, the caregiver-day-appointments
component does not run a change detection.
The HTML view now looks as follows.
<div class="row" *ngFor="let caregiver of calendar.caregivers">
<caregiver-detail [caregiver]="caregiver"></caregiver-detail>
<caregiver-day-appointments
*ngFor="let day of days"
[scheduleItems]="caregiver.scheduleItems"
[day]="day"
></caregiver-day-appointments>
</div>
For me, this change also makes it more readable and easier to work it.
Pure pipes to prevent method calls
After the previous change, we made the same mistake again.
We already grouped the appointments to the caregivers, but we still had to filter the appointments by day.
For this, we created a new method that filters the appointments for a given day.
While not that bad as previously, it still ran a lot of times, almost all of the runs were unnecessary.
To solve this, we didn't re-model our state because we didn't want to split up the appointments into days of the week.
This change would have made it harder to work with the caregivers' appointments, we still wanted to be able to easily access the appointments array to perform calculations.
That's why here, we opted for a Pure Pipe.
Angular executes a pure pipe only when it detects a pure change to the input value. A pure change is either a change to a primitive input value (String, Number, Boolean, Symbol) or a changed object reference (Date, Array, Function, Object).
Angular ignores changes within (composite) objects. It won't call a pure pipe if you change an input month, add to an input array, or update an input object property.
This may seem restrictive but it's also fast. An object reference check is fastβmuch faster than a deep check for differencesβso Angular can quickly determine if it can skip both the pipe execution and a view update.
For this reason, a pure pipe is preferable when you can live with the change detection strategy. When you can't, you can use the impure pipe.
The pipe will only execute when it detects that the input value(s) are changed.
A change is detected when the reference of the value is changed, just like the OnPush
strategy.
Because we re-modeled the state previously, we can assure that the reference to the appointments remains the same.
This has as result that the pipe will only execute once and the caregiver-day
component's change detection will only run one time.
<div class="row" *ngFor="let caregiver of calendar.caregivers">
<caregiver-detail [caregiver]="caregiver"></caregiver-detail>
<caregiver-day-appointments
*ngFor="let day of days"
[scheduleItems]="caregiver.scheduleItems | filterAppointmentsByDate: day"
[day]="day"
></caregiver-day-appointments>
</div>
@Pipe({ name: 'filterAppointmentsByDate' })
export class FilterAppointmentsByDatePipe implements PipeTransform {
transform(appointments: Appointment[], date: Date) {
return appointments.filter(appointment =>
dateEquals(appointment.date, date),
)
}
}
trackBy to decrease the number of DOM mutations
We knew that having method calls inside the HTML view were bad for performance.
But what didn't work as expected, was the trackBy
method.
We assumed that because we were using the trackBy
method, the methods inside the ngFor
template would only execute once.
But this is not the case. The trackBy
method only helps for the creation or the removal of the DOM node.
A function that defines how to track changes for items in the iterable.
When items are added, moved, or removed in the iterable, the directive must re-render the appropriate DOM nodes. To minimize churn in the DOM, only nodes that have changed are re-rendered.
I'm not not saying that the trackBy
method is not useful, because it is. It helps Angular to know when it must re-render DOM nodes, and when it should not. It ensures that only the affected nodes will be mutated. The less we have to do, the better.
Virtual scrolling for large lists
Because the list of caregivers might be large, a lot of component instances are created, together with their DOM nodes.
The logic inside these components will also be run, state is stored, subscriptions are established, and change detection cycles are run. This makes it unnecessarily harder for our devices. That's why we added virtual scrolling.
Virtual scrolling only creates the component instances that are visible in the view.
For this, we use the Scrolling CDK of Angular Material.
With this change, only the visible caregiver rows are created.
At its worse case, this (currently) reduces 50 caregiver component instances to 10 caregiver component instances.
This also is future proof as more caregivers could be added later.
Component-wise this means that 40 caregiver components will not be created and that all of the child components will not be created.
If each caregiver has 10 appointments a day, we're speaking about 400 child components that are not be created. We're not even counting the child components that are going levels deeper.
The best part, for us as developers, is that this is a minor change. It's only a 5-minute change, most of the time is spent to open up the documentation.
To implement it, simply wrap your component inside a cdk-virtual-scroll-viewport
component, set its itemSize
, and replace the *ngFor
directive to a *cdkVirtualFor
directive. Both directives share the same API. There's nothing more to it!
<cdk-virtual-scroll-viewport itemSize="160" style="height:100%">
<div
class="row"
*cdkVirtualFor="let caregiver of calendar.caregivers; trackBy: trackBycaregiver"
>
<caregiver-detail [caregiver]="caregiver"></caregiver-detail>
<caregiver-day-appointments
*ngFor="let day of days; trackBy: trackByDay"
[scheduleItems]="caregiver.scheduleItems | filterAppointmentsByDate: day"
[day]="day"
></caregiver-day-appointments>
</div>
</cdk-virtual-scroll-viewport>
Referential checks (NgRx)
Another culprit was the main NgRx selector, that returned the list of caregivers with their schedules.
The selector emitted too many times. After each change to the schedule, the selector is executed and returns a new result, with a new reference.
To make the application faster when a week navigation occurs we load the data for the next week when the current week is loaded.
We're re-using the same API calls to load the next week, as we do to load the current week. This also means that every time we receive an API response, we are modifying the state.
When the state is modified, the selectors receive a new input, and they will execute. Because we're using multiple API calls this means that the selector to build up the view will be executed repeatedly, after each API response. With each execution, the selectors emit a new value to the component which will trigger the Angular change detection.
But why do the selector think it's receiving a new value?
A selector is executed when it receives a different input, the selector uses an equality check ===
to know if the input was changed.
This check is cheap and will execute fast. This is fine for most of the cases.
In our case, we have a main selectCurrentWeekView
selector that builds up the view. It uses different selectors, and each selector is responsible to read the data from the state and to filter the items for the current week. Because we use the Array.prototype.filter()
method for this, it will always create a new reference and thus the equality check will fail. Because the "child selectors" all create new references, the main selector will execute for each change.
export const selectCurrentWeekView = createSelector((selectCaregivers, selectItemsA, selectItemsB, selectItemsC), (caregivers, a, b, c) => ...)
To get this resolved we can use the RxJS distinctUntilChanged
operator and verify if the new output is different from the current output. A simple JSON.stringify
check does the trick to check if the output is the same, but we first quickly check if the length is the same because it's faster in this case.
distinctUntilChanged: Returns an Observable that emits all items emitted by the source Observable that are distinct by comparison from the previous item.
The extra check is faster in comparison to running the Angular change detection for the whole component tree.
calendar = this.store.pipe(
select(selectCurrentWeekView),
distinctUntilChanged(
(prev, current) =>
prev.caregivers === current.caregivers &&
prev.caregivers.length === current.caregivers.length &&
prev.caregivers.reduce((a, b) => a.concat(b.scheduleItems), []).length ===
current.caregivers.reduce((a, b) => a.concat(b.scheduleItems), [])
.length &&
JSON.stringify(prev) === JSON.stringify(current),
),
)
While this solution works, it doesn't prevent the selector to be executed when the data remain the same.
If we want to limit the number of times the selector executes, we can take it a step further and modify the custom behavior of the NgRx selector.
A default selector createSelector
, uses the selector factory function to create a selector.
By default, a selector uses the memoization technique for performance reasons. Before the execution of the projection function, the memoize function relies on the isEqualCheck
method to know whether the input is changed. If it has changed, the selector's projection function will be called. After the execution of the projector, the result is also compared with the same isEqualCheck
, in order to not emit a new value.
The code within the NgRx repo looks like this.
export function defaultMemoize(
projectionFn: AnyFn,
isArgumentsEqual = isEqualCheck,
isResultEqual = isEqualCheck,
): MemoizedProjection {
let lastArguments: null | IArguments = null
let lastResult: any = null
function reset() {
lastArguments = null
lastResult = null
}
function memoized(): any {
if (!lastArguments) {
lastResult = projectionFn.apply(null, arguments as any)
lastArguments = arguments
return lastResult
}
if (!isArgumentsChanged(arguments, lastArguments, isArgumentsEqual)) {
return lastResult
}
const newResult = projectionFn.apply(null, arguments as any)
lastArguments = arguments
if (isResultEqual(lastResult, newResult)) {
return lastResult
}
lastResult = newResult
return newResult
}
return { memoized, reset }
}
export function isEqualCheck(a: any, b: any): boolean {
return a === b
}
function isArgumentsChanged(
args: IArguments,
lastArguments: IArguments,
comparator: ComparatorFn,
) {
for (let i = 0; i < args.length; i++) {
if (!comparator(args[i], lastArguments[i])) {
return true
}
}
return false
}
But like before, with the RxJS approach, this is not enough.
Our data is the same but the child selectors have created new references, thus the equality check thinks it receives new input.
To prevent the selector to be executed when the input data is the same, we can use the createSelectorFactory
function to create our own selector, with our own equality check.
The defaultMemoize
has a isArgumentsEqual
argument to compare the input, here where we're going to provide our custom comparer method. Just like before, the comparer will also make use of a JSON.stringify
check to compare the previous input with the current input.
export const selectCurrentWeekView = createSelectorFactory(projection =>
defaultMemoize(projection, argumentsStringifyComparer()),
)((selectCaregivers, selectItemsA, selectItemsB, selectItemsC), (caregivers, a, b ,c) => ...)
function argumentsStringifyComparer() {
let currentJson = ''
return (incoming, current) => {
if (incoming === current) {
return true
}
const incomingJson = JSON.stringify(incoming)
if (currentJson !== incomingJson) {
currentJson = incomingJson
return false
}
return true
}
}
Now, when one of the child selectors emit a new value, our argumentsStringifyComparer
method is used to check if the selectCurrentWeekView
's projector function should execute.
When the data for the current week is being loaded the data will be different for every response, and the selector will still be executed.
When the data is loaded for the next week the state gets updated but the child selectors still return the same data for the current week. With this change, the selector now will not pick this up as a change, and will not run.
This ensures that the component only receives a new value when the content of data has been changed. Because we check the selector's arguments first, we also prevent that the projection function of the selector is executed. For the heavier selectors, this is also a performance booster.
Preventing selector executions (NgRx)
With the current solution, our selector will still fire every time when the data has changed in the week view. The data of the view is partially loaded with multiple API calls. This means that the selector will be executed for each call. This is useless if all the calls follow-up fast after each other.
We can use the RxJS auditTime
operator to reduce the number of selector executions, and thus also change detection cycles.
auditTime: Ignores source values for duration milliseconds, then emits the most recent value from the source Observable, then repeats this process.
calendar = this.store.pipe(
auditTime(500),
select(selectCurrentWeekView),
startWith({ werknemers: [] }),
)
// or
calendar = this.store.pipe(
auditTime(0, animationFrameScheduler),
select(selectCurrentWeekView),
startWith({ werknemers: [] }),
)
This change ensures that the selector will only be called once for a given time, and not on each state change for the current week.
Don't forget to use the RxJS startWith
operator to set the initial state. Otherwise, the component will receive an undefined
value because the selector has not been executed yet when the components are initialized.
startWith: Returns an Observable that emits the items you specify as arguments before it begins to emit items emitted by the source Observable.
Detach components from the change detection
We went with this approach before applying some of the solutions already addressed.
Afterwards, we reverted this change as it has some downsides.
Nonetheless, it can still be helpful in some cases.
It's possible to detach a component, and its child components, from the Angular change detection cycles.
To do this, we can use the ChangeDetectorRef.detach()
method.
After this change, you'll notice that the component doesn't do much.
To run the change detection for the component, We have to manually call ChangeDetectorRef.detectChanges()
when we want to re-render the component.
In our case, we detached the caregiver component and we only ran the change detection when the caregiver data was changed, or when another property did change. To check if the caregiver data changed, we used the JSON.stringify
method again.
import { ChangeDetectorRef } from '@angular/core'
export class CaregiverScheduleComponent implements OnChanges {
@Input() otherProperty
@Input() caregiver
constructor(private cdr: ChangeDetectorRef) {
cdr.detach()
}
ngOnChanges(changes: SimpleChanges) {
if (changes.otherProperty) {
this.cdr.detectChanges()
return
}
if (changes.caregiver) {
if (changes.caregiver.isFirstChange()) {
this.cdr.detectChanges()
return
}
if (
changes.caregiver.previousValue.scheduleItems.length !==
changes.caregiver.currentValue.scheduleItems.length ||
JSON.stringify(changes.caregiver.previousValue.scheduleItems) !==
JSON.stringify(changes.caregiver.currentValue.scheduleItems)
) {
this.cdr.detectChanges()
return
}
}
}
}
This doesn't seem too bad, but it doesn't stop here.
We also had to call detectChanges
in the child components.
For example, we were using a material menu and the menu didn't open when we clicked on the trigger.
To open the menu, we had to call detectChanges
on the click event.
This is just one example, but we had to do this at multiple places.
This isn't straightforward.
If you're not aware that a component detached itself, it leads to frustration and minutes of debugging.
Conclusion
The biggest improvement we can make is to reduce the number of change detection cycles.
This will lower the number of function calls, and the number of re-renders.
The first step towards this is to work with immutable data.
When you're working with data that is immutable Angular and NgRx can make use of the ===
equality check to know if it has to do something. When the usage of JavaScript functions creates a new reference of an array (for example filter
and map
), we can override the equality checks. This can be done with RxJS or by creating a custom NgRx selector creator.
Every piece of logic that does not have to be run is a big win for the performance of an application. Therefore, limit the amount of work that has to be done with techniques like virtual scrolling to restrict the number of active components.
Make use of the trackBy
directive to let Angular know if something needs to be re-rendered.
Do not use methods in the HTML view, as these will be executed on every change detection cycle.
To resolve this, precalculate state wherever possible. When this is impossible, go for a pure pipe because it will be run frwer times in comparison to methods. When you're using a pipe it's (again) important to use immutable data, as the pipe will only execute when the input is changed.
Be aware of what triggers the change detection. If an input property of a component changes, or when it fires an event, it will trigger the Angular change detection.
Remember the quote "premature optimization is the root of all evil".
Most of these tips are only needed when the application doesn't feel snappy anymore.
Useful resources
- Optimizing an Angular application - Minko Gechev
- Angular Performance Workshop - Manfred Steyer
- Performance optimizations in Angular - Mert DeΔirmenci
- The Need for Speed (aka Angular Performance) - Bonnie Brennan
- A gentle introduction into change detection in Angular - Maxim Koretskyi
Follow me on Twitter at @tim_deschryver | Originally published on timdeschryver.dev.
Top comments (4)
Thanks so much for sharing this. I've yet to encounter these issues on any of my little projects, but if they crop up, I'll be ready!
P.S. There's currently a small typo in the conclusion section:
Thanks Andrew!
I think the headers in this article are not formatted correctly as I currently see
Root cause <!-- omit in toc -->
for example.Just FYI :D
Thanks for the catch!
I exported the article from my blog, and didn't think of removing the comments.
I guess I'll have to adapt my script to remove these in the future π