In this Article

Every now and then, we think about optimizing and tuning the performance of our applications. In terms of Angular, one of the first strategies that come to our minds is the OnPush change detection. But how does it work, and is it worth putting effort into using it? For what use-cases does it make a difference? This article explores precisely that and tries to answer those questions.

This is the second article of the mini-series "Condensed Angular experiences". We will explore concepts in Angular that seem simple but are very powerful and critical for the application architecture.

  1. Understanding Angular's Async pipe
  2. What is the hype with Angular's OnPush change detection?
  3. About smart and representational components
  4. Different approaches to complex and advanced forms in Angular

Version information

The data and code in this article are based on these technology versions:

Angular: 10+

What is Change Detection?

Angular, or any other SPA framework, must have a way to synchronize its internal model state to the view. Imagine displaying some user data - it does not matter what kind of data exactly. At first, we perform a network request to fetch such data, store it in some variable or state, and display it somehow to the user. The last part is where the change detection kicks in. Once fetched, data is often not available when rendering the page for the first time, or it changes over time. Since HTML is a static markup language, we need Angular to replace or display the values we want to change. Angular's change detection keeps the HTML view and our internal state in sync. It registers when an object or value is bound in the template and observes its changes. Then on different occasions, it compares the bound value to the displayed value and updates it if necessary.

Angular catches some events by monkey patching the browser APIs with the zone.js framework, effectively creating the NgZone. Other changes are registered internally by using @Inputs(), directives, binding to DOM events, and @Outputs(). You can configure the observed events, set the corresponding flags in a zone-flags.js file, and import it into your polyfills.ts before importing zone.js itself.

(window as any).__Zone_disable_requestAnimationFrame = true; // disable patch requestAnimationFrame
(window as any).__Zone_disable_on_property = true; // disable patch onProperty such as onclick
(window as any).__zone_symbol__UNPATCHED_EVENTS = ['scroll', 'mousemove']; // disable patch specified eventNames
import './zone-flags';
import 'zone.js/dist/zone';

Find out more on zone.js and the NgZone in this webinar by Christian Liebel about optimizing your Angular Web-App

Keep in mind that you only turn off features you are not using in your application. For example, requestAnimationFrame() is used by some charting libraries.

Now, let’s take a look at how the changes are detected.

How Does Angular's Default Change Detection Work?

Angular checks all components for changes on different occasions, even if some components did not change at all. These occasions include Network requests, mouse clicks, mouse scrolls, mouse moves, keyboard events, component events, timeouts, intervals, and more. You can also mark a component as changed manually by calling the markForCheck() function on the ChangeDetectorRef to check the next cycle. In general, all components are checked. One after another, once per cycle. It does not matter how often a component is marked for a check between two cycles. It is only checked once. The Angular team spent a lot of effort on highly optimizing the change check internally.

Many events can fire rapidly, resulting in the change detection (CD) being run at some point for all components in view.

But how can we define the performance of our application in this context? Let's try this simplified approach: The measurement indicator results from times we have to check for a change and the time it takes to perform the check.

change detection performance = (time to check a change) * (number of changes) or short P = N * C

As mentioned above, the time to check a change is optimized by Angular internally. It is on us to optimize the number of changes that are being checked.

Following, you find the Angular source code that is used to determine if an update is necessary. It accesses the bound value directly through a map and index, no need to iterate through all bindings. Afterward, the object's identity is directly compared, the view is updated if necessary, and the method returns if a view update must be made or not while updating the binding with the new value.

export function bindingUpdated(lView: LView, bindingIndex: number, value: any): boolean {
  // ...
    const oldValue = lView[bindingIndex];
    
    if (Object.is(oldValue, value)) {
        return false;
    } else {
        // run some additional checks and dev mode logic
    }
    
    lView[bindingIndex] = value;
    return true;
}

By default, Angular walks the application’s component tree, an array, from start to end.

Simplified default change detection

On its way, it searches for children and values that have to be compared and checked (e.g., @Inputs(), @Outputs(), and other template-bound references).

As seen in the code above, the check itself is straightforward. It compares the previous value with the current (or the current with the next one) and updates the component accordingly. Since we can pass mutable objects, the change detection has to check every binding in every template every time - Angular cannot know which of the values is bound at some point. Allthough, because we, as developers, know when values change and what value in an object changes, it becomes possible to tackle that last issue – and this is where the OnPush change detection strategy kicks in.

Let's take a closer look at how to call the change detection. Bear with me just before we finally explore the effect of the OnPush change detection strategy. We start by detaching a subtree from Angular change detection.

Detaching from Change Detection

It can be a good idea to detach a subtree from the change detection in some rare cases. Angular’s ChangeDetectorRef provides the method detach() for that.

export class SomeComponent implements OnInit {
    constructor(private readonly cdRef: ChangeDetectorRef) {}
    
    ngOnInit(): void {
        this.cdRef.detach()
    }
}

That way, a complete subtree is ignored, disregarding any change detection strategy or marked for check state. We can still run the change detection on such a detached tree if we want by using the detectChanges() method.

export class SomeComponent implements OnInit {
    constructor(private readonly cdRef: ChangeDetectorRef) {}
    
    someFunction(): void {
      // react to some event 
      // run change detection on this subtree
      this.cdRef.detectChanges()
    }
}   

The Difference Between markForCheck() and detectChanges()

As explained above, the change detection walks the DOM until the marked components are found.

In some cases, we pursue different behavior. It can become necessary to check all components below a specific host. In contrast to markForCheck(), the method detectChanges() on the ChangeDetectorRef also works if a subtree is not included in the change detection if it is detached from change detection.

It checks all the DOM from that component downwards. It does not wait for the next cycle but instead checks immediately – that’s also why it works in a detached state.

The Effect of OnPush

The OnPush strategy changes Angular’s change detection behavior in a similar way as detaching a component does. The change detection doesn't run automatically for every component anymore. Angular instead listens for specific changes and only runs the change detection on a subtree for that component. It runs for the subtree if an @Input() changes - or a component is marked for a check - until that component is reached. Remember, the comparison for a @Input() change is done by comparing identities (Object.is()).

If we pass a mutable object and only change the values of that object and not the object itself, the view would become out of sync with the model.

The strategy can be applied to one or more components in the tree at any place. The detection tree is optimized as soon as a component uses that strategy. The default change detection is only run to that point where it meets an OnPush component.

Default vs OnPush change detection

How does Angular know where to traverse the tree and where to stop?

Angular must be notified about changes when the OnPush strategy is used. Some notifications are built into the system – that is one reason for the immutable objects. By default, the @Input() decorator and the async pipe mark the component to be checked if the object changes.

I've prepared a simple demo where you can explore the effect of the OnPush change detection yourself.

Explaining the Demo Application

You can explore the difference between the default change detection strategy and the OnPush strategy in the demo below. The components are structured to be most easily modified if you want to change something or apply your use case. The demo allows you to generate differently nested components with different @Input(), HTML input elements, and buttons to explore how the change detection works and when a performance impact might be expected with which strategy.

Further, you can highlight all components affected by the change detection. However, enabling those highlights impacts the performance - that might be a clue or even more realistic compared to a real application.

When to Use OnPush

As demonstrated above, the OnPush change detection strategy enables Angular to reduce the change detection cycle time dramatically. It does so by only checking the necessary application parts. The test also shows a tipping point for every application or part of an application where it makes sense or is necessary to use the OnPush change detection. That tipping point is application and device-dependent, every device got a different threshold where it begins to stutter, and the frame rate drops below 60 FPS.

You can find the tipping point for your application by modifying the bootstrapping and run ng.profiler.timeChangeDetection({ record: true }). The lower the msPerTick result is, the better is the result.

However, there is also a new alternative to this. The Angular team introduced in this Blog the Angular DevTools extension for Chrome. With it, we can profile our application without modifying it. Furthermore, we can inspect the change detection cycles while profiling our application. Open Angular DevTools

You should consider using the OnPush change detection strategy if you run into FPS or other performance problems in your application.

There is also another benefit: Due to the reduced time and computations it takes to check the app, the OnPush strategy can also reduce your application's battery consumption, even if the application runs smoothly.

You can see a difference as soon as you use the OnPush strategy. Every component not checked reduces the duration of the CD cycle.

On my machine, a 2019 MBP 15”, it took 5500 Components without animation to make the CD cycle take more than 16ms. That means the application can’t reach its ideal performance of 60 FPS anymore, and we must use OnPush to reduce the CD cycle time. Keep in mind that my test application does nothing and is very simple – any other computation, animation, interaction, or complex design could increase the time it takes to run a change detection. You can see this effect by enabling the change detection highlight.

Conclusion

Understanding the change detection and OnPush in Angular is crucial to writing fast applications on every platform and device. We, as developers, tend to think it runs fast on our machine, so it runs fast on every device – in reality, that rarely is the case. Every device and every application is different. We are not forced to use OnPush in every app - and it is not a good idea to do so blindly. However, it is good to know exactly when it makes sense and how to use it properly. In any case, some apps require us to do so. Some performance issues are only solvable by using the OnPush change detection strategy.

Performance-wise and from an architectural point of view, it can make sense to remove conflicting parts of Angular. Creating a micro frontend with multiple Angular apps is challenging. Only one NgZone can be available at a time. One solution to this is to remove the NgZone from an Angular app. If everything is properly using OnPush - and we are handling edge cases ourselves by calling the markForCheck() and detectChanges() methods if needed - we could, in theory, remove the NgZone altogether. There would be no more events that must be caught by the NgZone and, therefore, no more requirement to use it. Removing the NgZone can also be a part of tuning your application's performance. There are multiple ways and techniques to do so.

You can find more on performance tweaking angular apps here. And subscribe to our monthly dev newsletter and make sure not to miss other articles, talks, and videos by our experts.

Related Articles

 | Max Schulte

Understanding Angular's Async Pipe What is the Hype with OnPush? (coming soon) Smart vs. Representational Components (coming soon) Different Approaches to Complex and Advanced Forms in Angular (coming soon) Every Angular developer knows the pipe. It is almost always present when…

Read article
 | Yannick Baron

Should You Use NgRx, in the First Place? This is a question that cannot be answered easily. Like every framework or library out there, it was made to do a certain thing, and to do it well. If your app is tiny and does not need an elaborate state management system, why bother. You…

Read article
 | Konstantin Denerz

In this article you will see how to enrich your test suite with functional and visual end-to-end tests for your Angular applications. Setting the Scene In the previous article, Visual Testing Of Angular Components With Storybook & Chromatic, I described how to use visual tests to…

Read article