Angular without zone.js: maximum performance

Angular developers owe a lot to the zone.js. She, for example, helps to achieve almost magical ease in working with Angular. In fact, almost always, when you just need to change some property, and we change it without thinking about anything, Angular re-renders the corresponding components. As a result, what the user sees always contains the latest information. This is just great.



Here I would like to explore some aspects of how the use of the new Ivy compiler (which appeared in Angular 9) can greatly facilitate the rejection of the use of zone.js.







By abandoning this library, I was able to significantly increase the performance of the Angular application running under heavy load. At the same time, I managed to implement the mechanisms I needed using TypeScript decorators, which led to very small additional costs of system resources.



Please note that the approach to optimizing Angular applications, presented in this article, is possible only because Angular Ivy and AOT are enabled by default. This article is written for educational purposes, it is not aimed at promoting the approach presented in it to the development of Angular projects.



Why might you need to use Angular without zone.js?



Before we continue, let's ask one important question: “Is it worth getting rid of zone.js, given that this library helps us to re-render templates with little effort?” Of course, this library is very useful. But, as usual, you have to pay for everything.



If your application has specific performance requirements, disabling zone.js can help meet those requirements. An example of an application in which performance is crucial is a project whose interface is updated very often. In my case, such a project turned out to be a real-time trading application. Its client part constantly receives messages via the WebSocket protocol. The data from these messages should be displayed as quickly as possible.



Remove zone.js from Angular



Angular can very easily be made to work without zone.js. To do this, you must first comment out or delete the corresponding import command, which is located in the polyfills.ts



file.









Commented out zone.js import command



Next - you need to equip the root module with the following options:



 platformBrowserDynamic()  .bootstrapModule(AppModule, {    ngZone: 'noop'  })  .catch(err => console.error(err));
      
      





Angular Ivy: detect changes yourself with detectChanges and ɵmarkDirty



Before we can start creating a TypeScript decorator, we need to learn about how Ivy allows you to invoke the process of detecting component changes, making it dirty, and bypassing zone.js and DI.



Two additional functions are now available to us, exported from @angular/core



. These are ɵdetectChanges



and ɵmarkDirty



. These two functions are still intended for internal use and are unstable - at the beginning of their names is the symbol ɵ



.



Let's look at how to use these features.



▍ ɵmarkDirty Function



This function allows you to mark a component, making it “dirty,” that is, in need of re-rendering. She, if the component was not marked “dirty” before its invocation, plans to launch the change detection process.



 import { ɵmarkDirty as markDirty } from '@angular/core'; @Component({...}) class MyComponent {  setTitle(title: string) {    this.title = title;    markDirty(this);  } }
      
      





▍ ɵdetectChanges Function



Angular internal documentation states that, for performance reasons, you should not use ɵdetectChanges



. Instead, it is recommended to use the ɵmarkDirty



function. The ɵdetectChanges



function synchronously invokes the process of detecting changes in a component and its subcomponents.



 import { ɵdetectChanges as detectChanges } from '@angular/core'; @Component({...}) class MyComponent {  setTitle(title: string) {    this.title = title;    detectChanges(this);  } }
      
      





Automatically detect changes using TypeScript decorator



Although the features provided by Angular increase the usability of development by letting the DI go around, the programmer can still be frustrated by the fact that he needs to import and call these functions himself to start the change detection process.



In order to simplify the automatic start of change detection, you can write a TypeScript decorator, which will independently solve this problem. Of course, there are some limitations, which we will discuss below, but in my case, this approach turned out to be exactly what I needed.



▍Introducing the @observed decorator



In order to detect changes, making as little effort as possible, we will create a decorator that can be applied in three ways. Namely, it is applicable to the following entities:





Consider a couple of small examples. In the following code fragment, we apply the @observed



decorator to the state



object and to the changeTitle



method:



 export class Component {    title = '';    @observed() state = {        name: ''    };    @observed()    changeTitle(title: string) {        this.title = title;    }    changeName(name: string) {        this.state.name = name;    } }
      
      







And here is an example with BehaviorSubject



:



 export class AppComponent {    @observed() show$ = new BehaviorSubject(true);    toggle() {        this.show$.next(!this.show$.value);    } }
      
      





In the case of Observable objects, using a decorator looks a bit more complicated. Namely, you need to subscribe to the observed object and mark the component as “dirty” in the subscription, but you also need to clear the subscription. In order to do this, we reassign ngOnInit



and ngOnDestroy



to subscribe and for its subsequent cleaning.



▍Creating a decorator



Here is the observed



decorator signature:



 export function observed() {  return function(    target: object,    propertyKey: string,    descriptor?: PropertyDescriptor  ) {} }
      
      





As you can see, descriptor



is an optional parameter. This is because we need the decorator to be applied to both methods and properties. If the parameter exists, it means that the decorator is applied to the method. In this case, we do this:





Next, you need to check with what type of property we are dealing with. It can be an Observable object or a regular object. Here we will use another internal Angular API. It, I believe, is not intended for use in regular applications (sorry!).



We are talking about the ɵcmp



property, which gives access to the properties processed by Angular after they are defined. We can use them to override the methods of the onInit



and onDestroy



.



 const getCmp = type => (type).ɵcmp; const cmp = getCmp(target.constructor); const onInit = cmp.onInit || noop; const onDestroy = cmp.onDestroy || noop;
      
      





In order to mark a property as one to be monitored, we use ReflectMetadata



and set its value to true



. As a result, we will know that we need to observe the property when the component is initialized:



 Reflect.set(target, propertyKey, true);
      
      





Now it's time to override the onInit



hook and check the properties when creating the component instance:



 cmp.onInit = function() {  checkComponentProperties(this);  onInit.call(this); };
      
      





We define the checkComponentProperties



function, which will bypass the properties of the component, filtering them according to the value set previously using Reflect.set



:



 const checkComponentProperties = (ctx) => {  const props = Object.getOwnPropertyNames(ctx);  props.map((prop) => {    return Reflect.get(target, prop);  }).filter(Boolean).forEach(() => {    checkProperty.call(ctx, propertyKey);  }); };
      
      





The checkProperty



function will be responsible for decorating individual properties. First, we check if the property is an Observable or a regular object. If this is an Observable object, we subscribe to it and add the subscription to the list of subscriptions stored in the component for its internal needs.



 const checkProperty = function(name: string) {  const ctx = this;  if (ctx[name] instanceof Observable) {    const subscriptions = getSubscriptions(ctx);    subscriptions.add(ctx[name].subscribe(() => {      markDirty(ctx);    }));  } else {    //    } };
      
      





If the property is an ordinary object, then we will convert it to a Proxy object and call markDirty



in its handler



function:



 const handler = {  set(obj, prop, value) {    obj[prop] = value;    ɵmarkDirty(ctx);    return true;  } }; ctx[name] = new Proxy(ctx, handler);
      
      





Finally, you need to clear the subscription after destroying the component:



 cmp.onDestroy = function() {  const ctx = this;  if (ctx[subscriptionsSymbol]) {    ctx[subscriptionsSymbol].unsubscribe();  }  onDestroy.call(ctx); };
      
      





The possibilities of this decorator cannot be called comprehensive. They do not cover all the options for its possible application that may appear in a large application. For example, these are calls to template functions that return Observable objects. But I'm working on it.



Despite this, the above decorator is enough for my small project. You will find its full code at the end of the material.



Analysis of application acceleration results



Now that we’ve talked a bit about Ivy’s internal mechanisms, and how to create a decorator using these mechanisms, it's time to test what we’ve got in a real application.



I, in order to find out the effect of getting rid of zone.js on the performance of Angular applications, used my Cryptofolio hobby project.



I applied the decorator to all the necessary links used in the templates and disabled zone.js. For example, consider the following component:



 @Component({...}) export class AssetPricerComponent {  @observed() price$: Observable<string>;  @observed() trend$: Observable<Trend>;   // ... }
      
      





Two variables are used in the template: price



(the asset price will be located here) and trend



(this variable can take values up



, stale



and down



, indicating the direction of the price change). I decorated them with @observed



.



▍ Project bundle size



To begin, let's take a look at how much the size of the project bundle has decreased while getting rid of zone.js. Below is the result of building the project with zone.js.









Result of building a project with zone.js



And here is the assembly without zone.js.









The result of building a project without zone.js



Pay attention to the polyfills-es2015.xxx.js



. If the project uses zone.js, then its size is approximately 35 Kb. But without zone.js - only 130 bytes.



▍Booting



I researched two application options using Lighthouse. The results of this study are given below. It should be noted that I would not take them too seriously. The fact is that, trying to find the average values, I got significantly different results by performing several measurements for the same application version.



Perhaps the difference in evaluating the two application options depends only on the size of the bundles.



So, here is the result obtained for an application that uses zone.js.









Analysis results for an application that uses zone.js



And here is what happened after analyzing the application in which zone.js is not used.









Analysis results for an application that does not use zone.js



▍ Performance



And now we got to the most interesting. This is the performance of an application running under load. We want to learn about how the processor feels when the application displays price updates for hundreds of assets several times per second.



In order to load the application, I created 100 entities that provide conditional data at prices that change every 250 ms. If the price rises, it is displayed in green. If reduced - red. All this could well seriously load my MacBook Pro.



It should be noted that while working in the financial sector on several applications designed for high-frequency transmission of data fragments, I have come across a similar situation many times.



To analyze how different versions of the application use processor resources, I used the Chrome developer tools.



Here's what the application that uses zone.js looks like









System load created by an application that uses zone.js



And here is how an application works in which zone.js is not used.









System load created by an application that does not use zone.js



We analyze these results, paying attention to the processor load graph (yellow):





Please note that these results were obtained with the Chrome Developer Tools window open, which also puts a strain on the system and slows down the application.



▍ load increase



I tried to make sure that every entity responsible for updating the price would produce 4 more updates every second in addition to what it already produces.



Here is what we managed to find out about the application in which zone.js is not used:





▍ Performance Analysis with Angular Benchpress



The performance analysis that I conducted above cannot be called particularly scientific. For a more serious study of the performance of various frameworks, I would recommend using this benchmark . For research, Angular should choose the usual version of this framework and its version without zone.js.



I, inspired by some ideas of this benchmark, created a project that performs heavy calculations. I tested its performance with Angular Benchpress .



Here is the code of the tested component:



 @Component({...}) export class AppComponent {  public data = [];  @observed()  run(length: number) {    this.clear();    this.buildData(length);  }  @observed()  append(length: number) {    this.buildData(length);  }  @observed()  removeAll() {    this.clear();  }  @observed()  remove(item) {    for (let i = 0, l = this.data.length; i < l; i++) {      if (this.data[i].id === item.id) {        this.data.splice(i, 1);        break;      }    }  }  trackById(item) {    return item.id;  }  private clear() {    this.data = [];  }  private buildData(length: number) {    const start = this.data.length;    const end = start + length;    for (let n = start; n <= end; n++) {      this.data.push({        id: n,        label: Math.random()      });    }  } }
      
      





I launched a small set of benchmarks using Protractor and Benchpress. The operations were performed a specified number of times.









Benchpress in action



results



Here is a sample of the results obtained using Benchpress.









Benchpress Results



Here is an explanation of the indicators presented in this table:





Now we will consider the analysis of the implementation of some operations in various application variants. Green shows the results of an application that uses zone.js, orange shows the results of an application without zone.js. Please note that only the rendering time is analyzed here. If you are interested in all test results, check here .



Test: creating 1000 lines



In the first test, 1000 lines are created.









Test results



Test: creating 10,000 rows



As the load on applications grows, so does the difference in their performance.









Test results



Test: appending 1000 lines



In this test, 1000 lines are appended to 10,000 lines.









Test results



Test: delete 10,000 rows



Here, 10,000 lines are created, which are then deleted.









Test results



TypeScript Decorator Source Code



Below is the source code of the TypeScript decorator discussed here. This code can also be found here .



 // tslint:disable import { Observable, Subscription } from 'rxjs'; import { Type, ɵComponentType as ComponentType, ɵmarkDirty as markDirty } from '@angular/core'; interface ComponentDefinition {  onInit(): void;  onDestroy(): void; } const noop = () => { }; const getCmp = <T>(type: Function) => (type as any).ɵcmp as ComponentDefinition; const subscriptionsSymbol = Symbol('__ng__subscriptions'); export function observed() {  return function(    target: object,    propertyKey: string,    descriptor?: PropertyDescriptor  ) {    if (descriptor) {      const original = descriptor.value;      descriptor.value = function(...args: any[]) {        original.apply(this, args);        markDirty(this);      };    } else {      const cmp = getCmp(target.constructor);      if (!cmp) {        throw new Error(`Property ɵcmp is undefined`);      }      const onInit = cmp.onInit || noop;      const onDestroy = cmp.onDestroy || noop;      const getSubscriptions = (ctx) => {        if (ctx[subscriptionsSymbol]) {          return ctx[subscriptionsSymbol];        }        ctx[subscriptionsSymbol] = new Subscription();        return ctx[subscriptionsSymbol];      };      const checkProperty = function(name: string) {        const ctx = this;        if (ctx[name] instanceof Observable) {          const subscriptions = getSubscriptions(ctx);          subscriptions.add(ctx[name].subscribe(() => markDirty(ctx)));        } else {          const handler = {            set(obj: object, prop: string, value: unknown) {              obj[prop] = value;              markDirty(ctx);              return true;            }          };          ctx[name] = new Proxy(ctx, handler);        }      };      const checkComponentProperties = (ctx) => {        const props = Object.getOwnPropertyNames(ctx);        props.map((prop) => {          return Reflect.get(target, prop);        }).filter(Boolean).forEach(() => {          checkProperty.call(ctx, propertyKey);        });      };      cmp.onInit = function() {        const ctx = this;        onInit.call(ctx);        checkComponentProperties(ctx);      };      cmp.onDestroy = function() {        const ctx = this;        onDestroy.call(ctx);        if (ctx[subscriptionsSymbol]) {          ctx[subscriptionsSymbol].unsubscribe();        }      };      Reflect.set(target, propertyKey, true);    }  }; }
      
      





Summary



Although I hope that you liked my story about optimizing the performance of Angular projects, I also hope that I did not incline you to rush to remove zone.js from your project. The strategy described here should be the very last resort that you can resort to in order to increase the performance of your Angular application.



First you need to try such approaches as using the OnPush change detection strategy, applying trackBy



, disabling components, executing code outside zone.js, blacklisting zone.js events (this list of optimization methods can be continued). The approach shown here is quite expensive, and I'm not sure that everyone is willing to pay such a high price for performance.



In fact, development without zone.js may not be the most attractive thing. Perhaps this is not only for the person who is involved in the project, which is under his full control. That is - it is the owner of dependencies and has the ability and time to bring everything to its proper form.



If it turned out that you tried everything and believe that zone.js is the bottleneck of your project, then maybe you should try to speed up Angular by detecting changes yourself.



I hope this article allowed you to see what Angular expects in the future, what Ivy is capable of, and what can be done with zone.js to maximize application speed.



Dear readers! How do you optimize your Angular projects that need maximum performance?








All Articles