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:
dimeloper / angular-optimization
A basic Angular app to showcase some performance optimisation techniques within Angular SPAs.
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:
Before optimization - Main page Web Vitals scores:
After optimization - Overview:
After optimization - Main page Web Vitals scores:
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.
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>
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.
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>
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 descriptivealt
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>
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>
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>
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>
}
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
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)