Supercharging Angular apps for better performance
Tags: angular, frontend, performance, webdev
Published: 2024-02-21 14:20:39

Over the past 2 years our team at [ZEAL](https://www.zealnetwork.de/) 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](https://dev.to/dimeloper/navigating-the-waters-of-core-web-vitals-in-2024-139i)).

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 dimeloper/angular-optimization %}

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](https://unlighthouse.dev/) 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](https://pagespeed.web.dev/) should be used instead.


![Initial Lighthouse scores of the main page](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/un9y60uwit2pxbqch5s5.png)

## 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`).

```html
<!--Main banner-->
<div class="banner">
  @if (banner != '') {
    <img [src]="banner" />
  }
</div>
```

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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/86l825602718yw7bpmex.png)

To improve our LCP score and prevent the layout shift we are going to remove client side code dependencies and utilise the [NgOptimizedImage](https://angular.io/api/common/NgOptimizedImage) directive.

```html
<!-- Main banner -->
<div class="banner">
  <img
    [ngSrc]="banner"
    placeholder
    fill
    priority
    alt="Pokemon Banner with Pikachu" />
</div>
```

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](https://github.com/dimeloper/angular-optimization/blob/main/src/app/pages-optimized/pokedex-optimized/pokedex.component.ts#L39) (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](https://web.dev/articles/fetch-priority)).
- 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](https://web.dev/articles/browser-level-image-lazy-loading)).
- Automatically generates a [preload link element](https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/rel/preload) 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](https://github.com/dimeloper/angular-optimization/blob/main/src/index.html#L18) 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](https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/rel/preconnect)).

## Optimising the Secondary Banners section

```html
<!--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>
```

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](https://angular.io/api/common/NgOptimizedImage) directive and its properties as we already did before.

```html
<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>
```

## 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.

```html
<!--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>
```

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](https://angular.io/guide/defer) 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.

```html
<!-- 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>
}
```

Notice the usage of the `placeholder` blocks and the nested `defer`. For more information make sure to consult the official Angular [documentation](https://angular.io/guide/defer).

## Auditing the final page

![Final Lighthouse scores of the main page](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/etngng0q09djt8eani3f.png)

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](https://github.com/dimeloper/angular-optimization) 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.