DEV Community

Cover image for Supercharging Angular apps for better performance
Dimitris Kiriakakis
Dimitris Kiriakakis

Posted on

Supercharging Angular apps for better performance

Over the past 2 years our team at ZEAL embarked on a challenging journey, dedicated to improving the Core Web Vitals scores of our Angular based web applications (should you wish to enhance your understanding of Web Vitals please check out my related article).

This was a long journey but if I were to summarise our learnings in a few words, these would be the following:

  • Always load the least possible amount of code and assets on the client’s browser.
  • Ensure that any element which is not part of the user’s viewport is lazy loaded (only loaded once it’s really needed).
  • In case you need to load media assets (images etc.), make sure to provide placeholders while the assets are still loading (a grey box with the same dimensions would do).

Understanding performance bottlenecks

In the beginning of our journey we encountered a great deal of difficulty in understanding the real issues across our Angular apps. Back then, a repository that would include examples of bad performance and proof of their impact on the audits, would shorten our learning curve. Therefore, I tried to put together such a repository:

GitHub logo dimeloper / angular-optimization

A basic Angular app to showcase some performance optimisation techniques within Angular SPAs.

angular-performance.png

Angular Optimization with Pokemon

This is a basic Angular app, featuring a pokedex to showcase some performance optimisation techniques within Angular SPAs This project was generated with Angular CLI version 17.2.0.

If you want to find out more about the optimization techniques we followed, please make sure to check out the related article Supercharging Angular apps for better performance

Performance measurements

The idea is the following, we start with a couple of pages that are far from optimized, and we take some initial measurements using unlighthouse* for faster feedback loops Then we are going to optimize our pages, while we are aiming for a better Core Web Vitals performance and better lighthouse scores overall.

Before optimization - Overview: overview-bad.png

Before optimization - Main page Web Vitals scores: pokedex-main-bad.png

After optimization - Overview: overview-optimized.png

After optimization - Main page Web Vitals scores: pokedex-main-optimized.png

If you want to run your checks yourself you can either…

Under src/app/pages-bad you will find 2 pages that are far from optimised and under src/app/pages-optimized you will find the optimised version of them.

The idea is simple: we take the pages that are far from optimised and we audit them using unlighthouse for faster feedback loops. Then we are going to optimise our pages, while we are aiming for a better Core Web Vitals performance and better lighthouse scores overall. Please keep in mind that if you need objective insights into the performance of your website, based on real-world data, Pagespeed Insights should be used instead.

Initial Lighthouse scores of the main page

Optimising the Main Banner section

If we take a deeper look at the pages-bad/pokedex-bad page we will be able to notice some serious issues. First of all, the Main banner (LCP area), depends on client side code (banner value depends on breakpointObserver to get a value) which automatically disables any benefits we could gain from server side rendering and eventually loads an asset that is not optimised at all (see pokemon-banner.png).

<!--Main banner-->
<div class="banner">
  @if (banner != '') {
    <img [src]="banner" />
  }
</div>
Enter fullscreen mode Exit fullscreen mode

Last but not least, there is no placeholder while the image loads which causes a big push to the rest of the page content once the banner image comes in.

Main page timeline

To improve our LCP score and prevent the layout shift we are going to remove client side code dependencies and utilise the NgOptimizedImage directive.

<!-- Main banner -->
<div class="banner">
  <img
    [ngSrc]="banner"
    placeholder
    fill
    priority
    alt="Pokemon Banner with Pikachu" />
</div>
Enter fullscreen mode Exit fullscreen mode

Keep in mind that we have used CSS on the .banner class to create a properly sized wrapper and the fill property to fill it. The placeholder property will automatically request a second, smaller version of our image, using our specified image loader (notice that the images have also been converted to relative URLs). This small image will be applied as a background-image style with a CSS blur while our image loads. The most important property here is priority though, which applies the following optimizations:

  • Sets fetchpriority=high which increases the priority of the image to ensure the LCP happens earlier (read more about it here).
  • Sets loading=eager which simply instructs the browser to load the image as usual, without delaying the load further if it is off-screen (read more about native lazy loading here).
  • Automatically generates a preload link element if rendering on the server — which in our case is true.
  • We've also replaced the image with a more optimised .webp alternative and we included a descriptive alt value to make our page more accessible.

Last but not least, we preconnect to the domain where our images originate. Preconnecting to the origin(s) that serve priority images ensures that these images will be delivered as soon as possible (more information here).

Optimising the Secondary Banners section

<!--Secondary banners-->
<mat-grid-list [cols]="cols" [rowHeight]="rowHeight">
  @for (pokemon of pokemons; track pokemon) {
    <mat-grid-tile>
      <mat-card class="pokemon-card">
        <mat-card-header>
          <mat-card-title-group>
            <mat-card-title>{{ pokemon.title }}</mat-card-title>
            <mat-card-subtitle>Extra large</mat-card-subtitle>
          </mat-card-title-group>
        </mat-card-header>
        <img mat-card-xl-image class="pokemon-teaser" [src]="pokemon.image" />
        <mat-card-content class="pokemon-preview">
          {{ pokemon.preview }}
        </mat-card-content>
      </mat-card>
    </mat-grid-tile>
  }
</mat-grid-list>
Enter fullscreen mode Exit fullscreen mode

At first glance we already recognise the same issue we had on the Main banner section, the mat-grid-list depends on variables that depend on the breakpointObserver. In this case we are going to remove entirely the Material grid logic as well as the mat-grid-list wrapper in order to replace it with a CSS grid alternative. This will also allow us to remove the related modules from the component imports. Finally we utilise the NgOptimizedImage directive and its properties as we already did before.

<div class="pokemon-teaser-grid">
  @for (pokemon of pokemons; track pokemon) {
    <mat-card class="pokemon-card">
      <mat-card-header>
        <mat-card-title-group>
          <mat-card-title>{{ pokemon.title }}</mat-card-title>
          <mat-card-subtitle>Extra large</mat-card-subtitle>
        </mat-card-title-group>
      </mat-card-header>
      <img
        class="pokemon-teaser"
        [ngSrc]="pokemon.image"
        [alt]="'Teaser showing ' + pokemon.title"
        width="160"
        height="160" />
      <mat-card-content class="pokemon-preview">
        {{ pokemon.preview }}
      </mat-card-content>
    </mat-card>
  }
</div>
Enter fullscreen mode Exit fullscreen mode

Lazy loading the rest of the content

As you might have already noticed, the part of the page content that is visible on the user’s viewport upon page load has already been optimised. So now we need to tackle the rest of our content.

<!--Dynamic content-->
<app-pokemon-list />
<div class="button-wrapper">
  <button mat-raised-button (click)="openDialog()">
    Pick your favorite via popup form
  </button>
  <button mat-raised-button (click)="openForm()">
    Pick your favorite via inline form
  </button>
  <!--  this forces client side rendering to demonstrate the impact of heavy calculations on the client -->
  @defer {
    <app-form [hidden]="hideForm" />
  }
</div>
Enter fullscreen mode Exit fullscreen mode

This part of the page loads some heavy components and blocks the main thread for way too long. Therefore we are going to make use of the Deferrable Views syntax. In short, none of these elements need to occupy processing power while the page loads. That’s why we are going to defer their load and show some static placeholders instead, until they become relevant to the user.

<!-- Dynamic content -->
@defer (on viewport) {
  <app-pokemon-list />
} @placeholder {
  <b>Favourite Pokemon</b>
}

@defer (on viewport) {
  <div class="button-wrapper">
    <button mat-raised-button (click)="openDialog()">
      Pick your favorite via popup form
    </button>
    <button mat-raised-button (click)="openForm()">
      Pick your favorite via inline form
    </button>
    <!-- here we secure that no heavy calculation will affect the page load -->
    <!-- additionally by using @loading we make sure that our INP score will not be affected -->
    <!-- since loading the form takes some time to do the calculation -->
    @if (!hideForm) {
      @defer {
        <app-form />
      } @loading (minimum 500ms) {
        <img
          class="loading-animation"
          alt="loading..."
          src="../assets/images/loading.gif" />
      }
    }
  </div>
} @placeholder {
  <div class="button-wrapper">
    <button mat-raised-button>Pick your favorite via popup form</button>
    <button mat-raised-button>Pick your favorite via inline form</button>
  </div>
}
Enter fullscreen mode Exit fullscreen mode

Notice the usage of the placeholder blocks and the nested defer. For more information make sure to consult the official Angular documentation.

Auditing the final page

Final Lighthouse scores of the main page

Our final performance audit, yielded flawless results, unequivocally demonstrating the effectiveness of the improvements we implemented. The targeted enhancements we made to critical aspects such as loading times, interactivity, and visual stability have significantly paid off, leading to an optimal user experience that aligns with the highest industry standards.

Don’t forget to check out the full repository where you will be able to review the full app configuration in regards to image loaders, server side rendering, client hydration and the optimisation of the other pages.

Top comments (0)