Angular Performance Tuning

Stepan Suvorov

Agenda

  • Performance
    • User perception of loading time
    • How to measure?
  • Network performance
    • Hosting Optimization
    • Bundle Size Optimization
    • SSR: Scully vs Universal
    • ServiceWorkes
  • Runtime Optimizations
    • Zones
    • Change Detection
    • Pure Pipes
    • Big List Optimization
    • WebWorkers
    • Memory Leaks

User perception of

loading time

Measure performance with RAIL

User perception

0 to 16 ms
60 fps

the best

16 to 100 ms

users feel like the result is immediate

100 to 1000 ms

accepted delay for complete task change view

>1000 ms

user loses the focus

>10000 ms

 users are frustrated and are likely to abandon tasks

process events in <50ms

60 FPS (<16ms)

or <10ms + browser replaint

  • use idle time to complete deferred work
  • < 50 ms
  • priority for the main thread
  • < 5s
  • mid-range mobile device
  • 3G

How To Measure

Network performance

Hosting Optimization

What to do? 

  • Use CDN
  • Use CDN on Global Cloud Provider
  • Use good compression (gzip, Brotli)
  • Combine small files
  • Split big files
  • Optimize your bundle

Bundle Size Optimization

Identify the problem

What to do? 

  • Split into smaller chunks
  • Load asynchronously (lazy modules)
    • with preloading strategies
  • Use light package alternatives
  • Do not include source maps
  • Target recent browsers
  • Monitor your bundle size( "budgets" section in ng-cli config)
  • With Tree Shaking in mind
  • Tree-shakable providers (@Injectable(provideIn))
  • provideIn: 'platform'

Server Side Prerendering

Why to SSR?

  • SEO

  • First screen

  • Social friendly preview

 

Scully

vs

Universal

What is Scully? 

Static Site Generator

  • Provide all Jamstack goals
  • Dot it for 100% of Angular projects
  • Require ~no effort to integrate
  • Support Markdown
  • Plugin system/ecosystem
  • ...

How Scully works ?

  • Find all your routes
  • Generate all the pages

Router Plugins

Render Plugins

Angular Universal

$ ng add @nguniversal/express-engine

$ ng add @scullyio/init

$ npm run dev:ssr
$ ng build
$ npm run scully
$ npm run scully:serve

init

dev env

prod env

$ npm run build:ssr
$ npm run serve:ssr
$ ng build --prod
$ npm run scully

you need nodejs server!

you can just upload your files to CDN

Angular Universal

rendering

is done with help of a real browser - Puppeteer

  • document/window/navigator
  • jQuery old plugins

with help of ngExpressEngine (without real browser)

JIT

is built for it

was not made to compile pages on fly (regeneration takes too much time)

partial

you can regenerate any specific page

--routeFilter "*/pages/*"

Angular Universal

benchmarks

speed

Angular Universal

benchmarks

file size

Service Workers

What are Service Workers?

  • can't access the DOM directly
  • control how network requests from your page
  • It's terminated when not in use (no own state)
  • extensive use of promises
  • HTTPS required

Install with AngularCli

ng add @angular/pwa

  • @angular/service-worker
  • enables ng-cli support
  • import and register SW in the app module
  • updates index.html with a link to the manifest file
  • ngsw-manifest.json

Why to use SW

  • Offline out of box
  • Application Updates (SwUpdate)
  • Push Notifications (SwPush)
  • Cashing resources

What's being cached?

  • index.html
  • favicon.ico
  • build artifacts (js and css bundles)
  • assets

ngsw-config.json

{
  "$schema": "./node_modules/@angular/service-worker/config/schema.json",
  "index": "/index.html",
  "assetGroups": [
    {
      "name": "app",
      "installMode": "prefetch",
      "resources": {
        "files": [
          "/favicon.ico",
          "/index.html",
          "/manifest.webmanifest",
          "/*.css",
          "/*.js"
        ]
      }
    },
    {
      "name": "assets",
      "installMode": "lazy",
      "updateMode": "prefetch",
      "resources": {
        "files": [
          "/assets/**",
          "/*.(eot|svg|cur|jpg|png|webp|gif|otf|ttf|woff|woff2|ani)"
        ]
      }
    }
  ]
}

ngsw-config

  • installMode
  • updateMode
  • prefetch
  • lazy

Caching External Resources

{
  "$schema": "./node_modules/@angular/service-worker/config/schema.json",
  "index": "/index.html",
  "assetGroups": [
    ...
    {
    "name": "externals",
    "installMode": "lazy",
    "updateMode": "lazy",
    "resources": {
        "urls": ["https://angular.io/assets/images/logos/angular/angular.svg"]
    }
    }
    ...
  ]
}

Caching HTTP Requests

{
  ...
  "assetGroups": [ ... ],
  "dataGroups": [
    {
      ...
    },
    {
      ...
    }
  ]
}

dataGroups

export interface DataGroup {
  name: string;
  urls: string[];
  version?: number;
  cacheConfig: {
    maxSize: number;
    maxAge: string;
    timeout?: string;
    strategy?: 'freshness' | 'performance';
  };
  cacheQueryOptions?: {
    ignoreSearch?: boolean;
  };
}

dataGroups

strategy: 'freshness' | 'performance';
  • performance(default) - cache first

  • freshness - network first

dataGroups

"dataGroups": [
    {
        "name": "random.org",
        "urls": ["https://api.random.org/**"],
        "cacheConfig": {
            "maxSize": 20,
            "maxAge": "7d",
            "strategy": "freshness"
        }
    }
]

Runtime Optimizations

Zones

zone.js

// zone.js simplified


//--------------------------------

setTimeout(_ => {
  console.log('some action');
}, 3000);

zone.js

// zone.js simplified
const oldSetTimeout = setTimeout;
setTimeout = (handler, timer) => {
  console.log('START');
  oldSetTimeout(_ => {
    handler();
    console.log('FINISH');
  }, timer);
}


//--------------------------------

setTimeout(_ => {
  console.log('some action');
}, 3000);

NgZone

// packages/core/src/zone/ng_zone.ts#L304
// ngzone simplified
 

function onEnter() {
  _nesting++;
}

function onLeave() {
  _nesting--;
  checkStable();
}

function checkStable() {
  if (_nesting == 0 && !hasPendingMicrotasks) {
    onMicrotaskEmpty.emit(null);
  }
}

onMicrotaskEmpty

// packages/core/src/application_ref.ts#L601
// ApplicationRef simplified

this._zone.onMicrotaskEmpty.subscribe({
    next: () => {
        this._zone.run(() => {
          this.tick();
        });
    }
});


tick() {
     for (let view of this._views) {
        view.detectChanges();
     }
}

ngZoneEventCoalescing

<div (click)="parent()">
  <button (click)="child()">Submit</button>
</div>

* from Angular9

platformBrowserDynamic()
  .bootstrapModule(AppModule, { ngZoneEventCoalescing: true })

Run code outside Zones

constructor(ngZone: NgZone) {
  ngZone.runOutsideAngular(() => {
      this._increaseProgress(() => {
        // reenter the Angular zone and display done
        ngZone.run(() => { 
          console.log('Outside Done!'); 
        });
      });
  });
}

CD without Zones

platformBrowserDynamic()
  .bootstrapModule(AppModule, { ngZone: 'noop' })
  .catch(err => console.error(err));
class AppComponent {
  constructor(app: ApplicationRef) {
    setInterval(_ => app.tick(), 100);
  }
}

Zone patch

<script>
    __Zone_disable_timers = true; // setTimeout/setInterval/setImmediate
    __Zone_disable_XHR = true; // XMLHttpRequest
    __Zone_disable_Error = true; // Error
    __Zone_disable_on_property = true; // onProperty such as button.onclick
    __Zone_disable_geolocation = true; // geolocation API
    __Zone_disable_toString = true; // Function.prototype.toString
    __Zone_disable_blocking = true; // alert/prompt/confirm
</script>

Change Detection

most part of illustrations for this topic were taking from @PascalPrecht slides

Each component has its own Change Detector

On Push

@Component({
  template: '<user-card [data]="data"></user-card>'
})
class MyApp {

  constructor() {
    this.data = {
      name: 'Jack',
      email: 'jack@mail.com'
    }
  }

  changeData() {
    this.data.name = 'John';
  }
}

MyApp

UserCard

<user-card [data]="data"></user-card>

MyApp

UserCard

<user-card [data]="data"></user-card>

data = {

    name: "John",

    email: "jack@mail.com"

}

MyApp

UserCard

<user-card [data]="data"></user-card>

data = {

    name: "John",

    email: "jack@mail.com"

}
oldData === newData

MyApp

UserCard

<user-card [data]="data"></user-card>

data = {

    name: "John",

    email: "jack@mail.com"

}
oldData === newData

Reference is the same, but the property could've changed (mutable), so we need to check

Angular is conservative by default and checks every component every single time

IMMUTABLE OBJECTS

@Component({
  template: '<user-card [data]="data"></user-card>'
})
class MyApp {

  constructor() {
    this.data = {
      name:  'Jack',
      email: 'jack@mail.com'
    }
  }

  changeData() {
    this.data = {...this.data, name: John};
  }
}
@Component({
  template: `<h1>{{data.name}}</h1>
             <span>{{data.email}}</span>`
})
class UserCardComponent {
  
  @Input() data;
}
@Component({
  template: `<h1>{{data.name}}</h1>
             <span>{{data.email}}</span>`,
  changeDetection: ChangeDetectionStrategy.OnPush
})
class UserCardComponent {
  
  @Input() data;
}

MyApp

UserCard

<user-card [data]="data"></user-card>

data = {
    name : "Jack",

    email: "jack@mail.com"

}
oldData === newData

What if there was no Input() change but we still need to run CD?

@Component({
  template: `<h1>{{data.name}}</h1>
             <span>{{data.email}}</span>`,
  changeDetection: ChangeDetectionStrategy.OnPush
})
class UserCardComponent {
  
  @Input() data;
  
  contructor(dataService: DataService) {
    dataService.onChange.subscribe(data => {
      this.data = data;
    });
  }
}

Input() did not change, CD propagation stops

@Component({
  template: `<h1>{{data.name}}</h1>
             <span>{{data.email}}</span>`,
  changeDetection: ChangeDetectionStrategy.OnPush
})
class UserCardComponent {
  
  @Input() data;
  
  contructor(dataService: DataService, 
              cd: ChangeDetectorRef) {
    dataService.onChange.subscribe(data => {
      this.data = data;
      cd.markForCheck();
    });
  }
}

Mark path until root for check

Perform change detection as usual

Restore original state

cd.markForCheck()

vs

cd.detectChanges()

cd.detach()

/cd.reattach()

@Component({
  ...
})
class UserCardComponent {
  
  contructor(cd: ChangeDetectorRef) {

      cd.detach();
      // start of a heavy operation 
      // for which we want to skip CD 
      // ... 
      // ...
      cd.reattach(); 
  }
}

Pure Pipes

Pure Pipes

@Pipe({
  name: 'mypipe',
  pure: false/true        <----- here (default is `true`)
})
export class MyPipe {
  
}

Pure Function

function sum(a, b) {
  return a + b;
};

sum(1, 1);  // 2
sum(1, 1);  // 2
sum(1, 1);  // 2



let state = 0;
function changeState(v) {
  return state += v;
}

changeState(1);  // 1
changeState(1);  // 2
changeState(1);  // 3

How is it related to performance?

Pure Pipes on steroids

npm i memo-decorator --save

Minko Gechev

import memo from 'memo-decorator';

...
  @memo()
  transform(value: string): string {
    ...
  }

Big Lists Optimisation

Virtual Scroll

The image was taken from zangxx66/vue-virtual-scroll-list

Virtual Scroll in Angular

Angular CDK

Virtual Scroll

Web Workers

Web Workers

ng generate webWorker my-worker

const worker = new Worker(`./my-worker.worker`, 
                          { type: `module` });
worker.onmessage = ({ data }) => {
   console.log(`page got message: ${data}`);
};
worker.postMessage('hello');

Memory leaks

What is memory leak?

What is memory leak?

What is memory leak?

How to detect memory leak?

Performance Monitor in Chrome DevTools

How to detect memory leak?

Performance Monitor in Chrome DevTools

What's the cause?

Unhandled Subscription

What to do with a Subscription?

  • unsubscribe always!
  • ngOnDestroy()
  • decorator
  • takeUntil() (takeWhile(), first(), last(), take())
  • Async Pipe

Thank you for your attention.

Questions?

Grazie per l'attenzione

Angular Performance Tuning

By Stepan Suvorov

Angular Performance Tuning

  • 997