Improve page performance and LCP with NgOptimizedImage

Post Editor

Explore mechanisms of NgOptimizedImage directive to improve overall page performance, targeting especially the Largest Contentful Paint (LCP) metric from Core Web Vitals. Enhance pages, make the best user experience and improve the web.

23 min read
0 comments
post

Improve page performance and LCP with NgOptimizedImage

Explore mechanisms of NgOptimizedImage directive to improve overall page performance, targeting especially the Largest Contentful Paint (LCP) metric from Core Web Vitals. Enhance pages, make the best user experience and improve the web.

post
post
23 min read
0 comments
0 comments

Recently the Aurora project started a collaboration with the Angular team to improve performance and share best practices regarding loading images within the framework. If you don’t know, Aurora is a collaboration between Chrome and open-source tooling.

Angular and Aurora worked closely to deliver the NgOptimizedImage directive that should bring plenty of performance tweaks and far better DX. The directive was submitted as a “developer preview” feature in Angular version 14.2, and now it is fully operable in Angular 15!

Investigating and learning this directive is our ultimate goal for this article. We are going to check if it is really that useful, and how we can use it, and jump into the source code to check how it is implemented.

Images performance
Link to this section

In this year’s (2022) HTTP Archive’s annual state of web report called Web Almanac, we can find many fascinating statistics regarding websites and their performance. What caught my attention particularly, was the part of the report dedicated to images and how important they are in terms of the site performance and perceived load speed.

Images on our websites impact the overall page weight drastically and to achieve a great user experience we should consider analyzing and optimizing our images. According to the Web Almanac analysis on page weight, the median weight of images on mobile was 881 KB, while the total weight median of the entire website was 2019 KB. So images alone take almost the half of page weight.

Images are present everywhere, 99.9% of all websites requests at least one image. This single image for the majority of websites has at least 100KB. Additionally, at least one image with a weight over 1MB is present on 10% of pages.

As you see, the images on the web are serious business and we can’t underestimate their impact on the site performance. In fact, in 70% of pages on mobile, and 80% of pages on the desktop the image was responsible for the website render time and because of that also the perceived load speed.

Speaking more technically, the Largest Contentful Paint (LCP) responsible element in these pages was an image!

The LCP
Link to this section

What is the LCP, and why is it so important when analyzing website performance?

LCP means Largest Contentful Paint and it is one of the key metrics proposed in the Web Vitals. That are created by Google, to improve user experience on the web. LCP become so important it is now included in the Core Web Vitals which is a subset of the Web Vitals. The Core Web Vitals consists of three metrics, each one representing a different aspect of user experience, that can be measured in the field.

The purpose of Largest Contentful Paint is to measure the loading experience - so it is a user-centric metric that should help understand perceived load speed. The way it calculates the outcome is by measuring the time until the page's main content has likely loaded.

LCP score interpretation according to Core Web VitalsLCP score interpretation according to Core Web Vitals
LCP score interpretation according to Core Web Vitals

According to Core Web Vitals, the LCP should take less than 2.5 seconds (on both mobile and desktop devices). An indication of poor LCP is when it takes more than 4 seconds. Anything in between simply needs improvement.

The metric is measured only for the visible viewport and according to Web Almanac, in 80% of sites, the element that is responsible for poor LCP result is an image element. This is not a big surprise, knowing that the images across the web are usually not well optimized, they are pretty heavy which makes the loading slower than other content on the page, and they exist on almost every page.

The support for image optimization is growing rapidly, and browsers are including more and more mechanisms to improve working with images but still we as developers are not using the full potential of it. In the Web Almanac we can read that setting loading=lazy attribute raised from 0% of usage in 2020, to almost 25% in 2022 - still a lot of areas to improve! 10% of LCP images are using the wrong loading attribute, and only 34% of pages are using the srcset attribute.

It looks like the websites could benefit a lot from these functions, and LCP could be improved rapidly, so why it is not popular yet?

It could be because of a lack of knowledge (for that I hope this article will help a bit), but also because of not having great Developer Experience in applying these optimization functions. In many frameworks, this could be improved to make it easier to enhance the page performance.

Optimizing the LCP in the Angular app
Link to this section

We will go iteratively. Improving the performance of the application step by step, and measuring the LCP for each change. Before jumping into the code and the actual optimization implementation, I will quickly explain my assumptions for measuring the results, and the specification for the application.

The lab environment
Link to this section

My assumptions for the test were made to mirror the “average” web app, based on the data from WebAlmanac 2022 and 2021. Here they are:

  1. there will be 15 images (most of the pages have at least 15 images), one hero image on the top, and then 14 in the text as a content illustration
  2. images will be in high resolution 1920px, around 800kB per image (this one is above the median weight, but I wanted to go with good-quality images)
  3. we will serve them in jpg format from a separate server from our frontend application (to get some traffic/connection lag when fetching them)
  4. measuring LCP will happen on two environments: the app deployed on Vercel and measured by WebPageTest, and the app served on localhost, measured via Lighthouse in devtools.
  5. a resulting LCP will be a median of LCPs taken from 3 separate measurements

The application’s source code is available on Github here, and each optimization improvement is done on a separate branch.

Stage 0: base setup
Link to this section

Our test subject will be a simple Angular application with 15 images and some random text in between the images. It is all contained in a single component, without much finesse. The thing we are most interested in is the image to be an LCP element.

The screenshot below presents how the application looks on mobile devices — so basically it is a single image at the top, and a bunch of text below.

Content imageContent image
screenshot of the application

Component’s class contains only some static assets references:

<>Copy
@Component({ selector: 'app-root', templateUrl: './app.component.html', }) export class AppComponent { readonly heroImage = 'external-source/pizza-hero.jpg'; readonly images = [ 'external-source/pizza-1.jpg', 'external-source/pizza-2.jpg', ... ]; }

The template consists of the hero image and the header on the top, and then below the text content interspersed with pictures:

<>Copy
<img [src]="heroImage"> <h1>Let's talk pizza!</h1> <section> <article *ngFor="let image of images"> <p> Lorem ipsum ... </p> <img [src]="image"> </article> </section>

Note how images are declared. In a base implementation, the image elements do not differ from each other, and they are implemented using the most basic syntax possible. Image elements only contain a single src input assigned to the external URL.

Measure LCP
Link to this section

We are going to start with the Lighthouse test running on localhost. This results in an LCP value of 20.8 seconds. Lighthouse tests are going to be slower than the app deployed on Vercel and tested with WebPageTest - so don’t worry about that quite an enormous result. We are more interested in the trend we are gonna get, and hopefully, we will see it decreases, while we improve our app.

The WebPageTest indicates an LCP value of 6.5 seconds. WebPageTest details provide something called Waterfall View, which is going to be a great help for us. It presents the browser activity divided into particular resources, the browser’s needs to download, load, encrypt, run, and so on. The Waterfall for our base implementation is the following:

Waterfall View for the app with base implementationWaterfall View for the app with base implementation
Waterfall View for the app with base implementation

Analyzing this graph bar chart can give us a lot of information on how the browser interprets LCP and how we can improve that. The LCP moment is indicated on the graph by the green-dotted line. You may see it here, placed between 6 and 7 seconds.

So what conclusions can we come to?

  • images are being fetched after downloading and processing all JS, CSS, and font resources
  • the LCP element is the pizza-hero.jpg image
  • browser waits for 2 seconds before it starts fetching
  • in parallel, several images are being downloaded (we may see this by looking at the darker color at the image bar - it indicates when the browser actually downloads the image, while the lighter means the browser requests the download to happen)
  • the pizza-1.jpg which is not visible to the user is being fetched & processed before the top image!
  • the pizza-hero.jpg takes above 4 seconds to download

Here is the link to the test details if you want to check on your own: https://www.webpagetest.org/result/221204_AiDc1M_5JD/1/details

Conclusions
Link to this section

We can assume, based on the Waterfall View, that we need to optimize the images - by focusing on the top one (pizza-hero.jpg) we could highly improve the LCP. We need to tackle several things, which we will call tasks:

  • if possible start requesting the images earlier
  • if possible prioritize the hero image to be downloaded first and then download the rest of the images
  • if possible request smaller/lighter images to download them faster

Stage 1: Introducing NgOptimizedImage and lazy loading
Link to this section

The first thing we are gonna do is implementing the NgOptimizedImage directive, which is actually extremely easy. We need to import the NgOptimizedImage. The directive is marked as standalone, so you may import it to your ngModule or standalone component.

Now, to use it, we need to replace the src attribute with ngSrc — the value though stays the same. The final result is presented in the following snippet:

<>Copy
<img [ngSrc]="heroImage"> <article *ngFor="let the image of images"> <p> Lorem ipsum ... </p> <img [ngSrc]="image"> </article>

This simplified syntax is available to us because both the directive selector (img[ngSrc]) and the input ngSrc for the image source targets the same ngSrc attribute.

However, If we run it, we will get an error, that images are missing the width and height attributes — which are required by the directive. We need to add them:

<>Copy
<img [ngSrc]="heroImage" width="1920" height="1080"> <article *ngFor="let the image of images"> <p> Lorem ipsum ... </p> <img [ngSrc]="image"> </article>

Let us not optimize it any further, and measure the LCP by adding base NgOptimizedImage as the only change. Before that, let’s check what has really changed.

In-depth bits
Link to this section

We can inspect using the devtools our image elements, and analyze what are the changes in there. Because right now we are not differentiating any images, it doesn’t matter which one we are looking at - they are all the same. Below is the result of a rendered image without the NgOptimizedImage:

<>Copy
<img _ngcontent-hik-c1="" alt="main banner with image of pizza" src="<https://image-optimization-app.vercel.app/assets/pizza-hero.jpg>">

and here is the result with added NgOptimizedImage directive:

<>Copy
<img _ngcontent-rta-c2="" alt="main banner with image of pizza" width="1920" height="1080" loading="lazy" fetchpriority="auto" src="<https://image-optimization-app.vercel.app/assets/pizza-hero.jpg>">

Clearly, there are four new attributes. These are width, height, loading, and fetchpriority. The width and heightwere added by me, but the loading and fetchpriority are added automatically by the NgOptimizedImage.

The loading attribute tells the browser how to load the image. It can go with:

  • eager - this is the default one, and it loads the image immediately
  • lazy - it defers the loading until the time the image will most likely be visible on the user’s screen

As we see, the directive automatically sets the loading attribute to lazy, to defer the loading and therefore improve performance in most typical use cases.

Next, we have the fetchpriority which determines in which order the images should be fetched in relation to each other. There are three options:

  • auto - an automatic priority
  • high - indicates high-priority images
  • low - indicates low-priority images

Our directive sets the fetchpriority to auto by default.

Does the directive do anything more in this initial stage? Yes, it does, and it is actually a lot!

The directive checks an amazing number of requirements and asserts that it is used in the best way possible. When the required assertion passes it generates HTML attributes, styles rules, srcSets, and creates preload link tags when running with SSR.

An example of the assertions in our code example could be checking for width and height settings — which is obligatory. The only exception from that is to use the fill attribute, which is an alternative way of setting the image size - it is more responsive and fills its container. We will be implementing the fill attribute later on.

If we don’t set up either of these, the directive will throw an error message explaining that requirement in detail. Additionally, if our width and height settings would cause an image to be distorted - for example when width and height result in a different ratio than the actual image ratio — the directive will warn us about that and help fix it.

In total, the directives can throw 13 runtime errors when required assertions fail, and 6 warnings to ensure best practices.

Such delightful communication with the developer makes a great development experience and ensures all best practices. From my perspective, this is really a state-of-the-art directive in many aspects — including DX.

Measure LCP
Link to this section

Starting from the Lighthouse run on localhost — we’ve got an LCP of 23.1 seconds, which means we have an increase of LCP by 2.3 seconds. Before jumping into any assumptions, let's confirm the trend with the WebPageTest.

WebPageTest measures the LCP to be 6.3 seconds, which also means an increase, of 0.1 seconds. Let us take a look at the Waterfall graph:

Waterfall View for Stage 1Waterfall View for Stage 1
Waterfall View for Stage 1

In the Waterfall, we can see the regression. Even the favicon is loaded before the images on the page. Both pizza-hero.jpg and pizza-1.jpg images are being downloaded simultaneously. The only good thing is, the other images are not fetched at all! And this is great because they are not visible on the screen (they are way below) so there is no point in fetching them at init.

Here is the link to the test details if you want to check on your own: https://www.webpagetest.org/result/221204_BiDcNN_5G9/2/details/

Conclusions
Link to this section

We can be sure that the lazy-loading which comes by default with NgOptimizedImage works — the images visible on the viewport (or nearly visible) are the only ones being fetched.

Ideally, we would like to stay with the lazy-loading for all images except the pizza-hero.jpg which is our LCP element and we want to prioritize fetching it. Along the way we still to need take care of conclusions from the previous stage:

  • if possible start requesting the hero image earlier
  • if possible prioritize the hero image to be downloaded first and then download the rest of the images
  • if possible request smaller/lighter images to download them faster

Stage 2: Prioritize LCP element
Link to this section

Now, when we have the directive implemented, optimizing it further will be easier. We just need to follow suggestions from the directive itself and take care of our tasks list. We will start with the prioritization issue. You can mark this as a general remark — if your LCP element is an image, you should prioritize its loading.

To do this with NgOptimizedDirective we need to add a priority attribute to the declaration.

<>Copy
<img [ngSrc]="heroImage" width="1920" height="1080" priority> <article *ngFor="let the image of images"> <p> Lorem ipsum ... </p> <img [ngSrc]="image"> </article>

Please note, that we added the priority attribute only to the first image — which in our case is the LCP element.

Understanding priority attribute
Link to this section

Let us take a closer look at the runtime version and check the differences with the previous one. We are only interested in the first image tag because there were no changes to the rest of them. Below is the previous result, from Stage 1:

<>Copy
<img _ngcontent-rta-c2="" alt="main banner with image of pizza" width="1920" height="1080" loading="lazy" fetchpriority="auto" src="<https://image-optimization-app.vercel.app/assets/pizza-hero.jpg>">

and here is our new result, from Stage 2:

<>Copy
<img _ngcontent-rxd-c2="" alt="main banner with image of pizza" width="1920" height="1080" priority="" loading="eager" fetchpriority="high" src="<https://image-optimization-app.vercel.app/assets/pizza-hero.jpg>">

The priority attribute was added, but it is only a directive internal input. The essential change is actually in the fetchpriority attribute. Its value was changed from auto to high. This is the real deal, and we should see an improvement in the measurements.

There is no other change, the directive simply updates the fetchpriority based on the priority input variable. It also will warn us about not having the preconnect tag present for the priority image. We will think about that in the conclusions section.

Measure LCP
Link to this section

The Lighthouse run indicated an LCP of around 20.8 seconds, so lower than the previous one and exactly the same as our base implementation. Not much improvement so far, but when looking at the WebPageTest we got 6.0s LCP, which is the lowest result so far. Let’s take a closer look at the Waterfall:

Waterfall View for Stage 2Waterfall View for Stage 2
Waterfall View for Stage 2

There are two clear outcomes. The first one, the pizza-hero.jpg is finally prioritized and fetched completely before going for the pizza-1.jpg. Second, our hero image finally beat the favicon in the race.

Here is the link to the test details if you want to check on your own: https://www.webpagetest.org/result/221205_AiDcCM_E6G/1/details/

Conclusions
Link to this section

Let us analyze the Waterfall more — there is a quite big gap between initializing fetching resources at around 1.5 seconds and starting to fetch the hero image. The actual download starts in 2.5 seconds, which is way too late.

Between the second 2.0 and 2.5, three things are going on - DNS, connect, and SSL. These three tasks are simply about establishing the connection to the external server, but not actually downloading anything. We can’t rush JS to run our Angular app faster and magically know he has to download the hero image, but we could tell the browser that the “connection” part, could be done as soon as possible while waiting for JS to decide what to download.

This issue can be resolved by adding the preconnect tag link, and the directive is actually informing about that in the warning.

Let us go back, and check the progress on the previous conclusions:

  • ✅ if possible start requesting the hero image earlier
  • ✅ if possible prioritize the hero image to be downloaded first, and then download the rest of the images
  • if possible request smaller/lighter images to download them faster

We have one more task to solve, but first, let’s work on the preconnect.

Stage 3: Preconnect
Link to this section

This article explains the preconnect mechanism in detail. For us, it is essential to understand, that we can give hints to the browser about the resources. So if we are certain, that we will fetch resources from some server, and the browser will have to establish the connection, we can tell that to the browser at the start.

The technique is actually very simple - it is only about adding one link element to the page <head>. Take a look at the following snippet:

<>Copy
<link rel="preconnect" href="<https://example.com>">

Fairly simple, isn’t it? It is just a link, with the rel attribute set to preconnect, and the URL which contains our resources. Have in mind that we don’t need to establish the connection for every asset stored in some server, we only need to do it once, so there is no point in making the preconnect link tag, for every resource — one per server is enough.

That’s exactly what we are going to do, in this optimization step. Our resources are stored in an app deployed on Vercel, and that’s the place we will point our preconnect tag to. So in the index.html, we are adding one more line to the <head>element of the page.

<>Copy
<link rel="preconnect" href="<https://image-optimization-app.vercel.app>">

That’s it.

Explanation
Link to this section

There is nothing more in this step regarding the implementation of the NgOptimizedImage. The preconnect tag is a browser mechanism, and the directive only ensures we are using it.

It is worth mentioning that NgOptimizedImage is not only displaying the warning, when we are not using the preconnect link tag for the priority element, but also generating the exact code and logging it with the rest of the message. So really the only thing we need to do is to copy & paste the prepared snippet.

Measure LCP
Link to this section

Alright, let’s check the results. Lighthouse on localhost results in the very same time as in the previous step, so the 20.8 seconds. However, the WebPageTest reports a significant decrease! We have lowered the LCP from 5.4s to 4.5s. This is great! I’m not sure why the localhost app is not influenced by this change, and without such a detailed waterfall graph for localhost, I’m unable to figure out a reason. Maybe somehow the connection was cached.

We can look at the Waterfall view from the WebPageTest:

Waterfall View for Stage 3Waterfall View for Stage 3
Waterfall View for Stage 3

The graph clearly presents that we succeeded in establishing the connection earlier. The DNS + connect + SSL tasks for the assets are now done way before fetching the actual resource. It was actually done at the same time when JS critical files were being downloaded.

Here is the link to the test details if you want to check on your own: https://www.webpagetest.org/result/221205_BiDcY8_E63/3/details/

Conclusions
Link to this section

Because the establish connection phase happens earlier, the image can be downloaded as soon as the app realizes it needs to display the resource. We cannot speed up that anymore, but what we can do is request smaller/lighter images to download them faster.

Stage 4: Responsive images
Link to this section

There is no point in serving a 4K image to the browser if the user’s viewport has a resolution of around 500px. The user won’t see the difference anyway, but it will wait significantly longer to download the resource — especially on slower (mobile) connections.

At the same time, we don’t want to serve always a low-resolution image, to load fast on mobile, but have a low quality on desktop. Speaking about the download time and image quality, we would most certainly want to eat a cake and have it too!

How can we serve different images depending on the screen size? The image element has an attribute called srcset, described in detail here. Essentially it specifies various image resources with some rules describing when to apply the resource. There are two ways to describe the rule. We can go with pixel density, or the exact width size and provide additional sizes attribute for specifying the layout widths.

Hopefully, the directive once again helps us with that and automatically generates the srcset. There are a few ways we could implement that technique:

  • automatic srcset generation — based on the width and height attributes. This doesn’t work for responsive images. It will generate different srcset resources based on the pixel density, but not various screen sizes.
  • automatic srcset generation — based on the sizes attribute. This works for responsive images because it updates the srcset to work with different screen sizes, using the responsive breakpoints. The breakpoints are provided by default, but you can override them if you want to set some specific screen-size breakpoints.
  • manually srcset declaration — this is also a developer-friendly version, and we don’t have to put all the boilerplate code, but only specify the sizes for which the directive will generate the complete srcset.

We will go with fully responsive images, based on the screen size. In our app, images always take the entire screen width, so we need to include that in our approach. We will do two changes:

  • replace width & height with fill attribute to take the entire width,
  • add sizes attribute and set it to 100vw which is the screen width.

The following code snippet is the result of the changes:

<>Copy
<img [ngSrc]="heroImage" fill priority sizes="100vw"> <article *ngFor="let the image of images"> <p> Lorem ipsum ... </p> <img [ngSrc]="image" fill> </article>

Why did we change the approach from fixed width & height to responsive fill? It is because, in our app, the images takes always full width, and this will result in a need for different images based on the viewport size, so for example on mobile screen size we may need to download lighter images than on the big desktop.

Of course, no matter which method of generating the srcset we will choose, we still need to generate and serve these resources, which could be tedious to do. That’s why we will use Imgix. It is a CDN platform, which optimizes images for us. CDN uses a specific URL format to get resources for a given srcset, more info about that is here. That way, I don’t have to manually create various versions of my pizza-hero.jpg image, but simply upload that to Imgix and then request specific resources.

I’ve chosen Imgix to be my image service provider, because it is one of the CDNs that Angular supports automatically with preconfigured loaders, and I don’t need to write any additional code. I will explain how to create custom ones later, but for now, let’s stick with the easiest one.

To set up the CDN to work automatically with the NgOptimizedImage, producing the srcset we need to provide the loader in the providers array (in your ngModule or standalone component):

<>Copy
providers: [ provideImgixLoader('<https://my.base.url/>'), ]

My Imgix setup exposes assets under https://maciejwojcik.imgix.net so we can add that to the loader like this:

<>Copy
@NgModule({ ... providers: [ provideImgixLoader("<https://maciejwojcik.imgix.net>") ] })

To get an image, for example, the pizza-hero.jpg, I need to get the resource from https://maciejwojcik.imgix.net/pizza-hero.jpg and because the base route is already provided in the Imgix loader, there is no need to repeat ourselves in the component class. That simplifies our asset declarations, to only asset names:

<>Copy
@Component({ selector: 'app-root', templateUrl: './app.component.html', }) export class AppComponent { readonly heroImage = 'pizza-hero.jpg'; readonly images = [ 'pizza-1.jpg', 'pizza-2.jpg', ... ]; }

We can run our app and check in the devtools network tab, what resources are being fetched:

<>Copy
URL: <https://maciejwojcik.imgix.net/pizza-hero.jpg?auto=format&w=828>

So, the URL correctly points to our CDN with the correct asset name, but that’s not the only change. There are query params automatically generated by the NgOptimizedDirective! This is a huge DX improvement and makes following best practices way easier. Imagine — I only added a few lines of code, and now I’m able to get images customized for the user’s screen!

In-depth bits
Link to this section

Let us once again, take a look at the runtime version. Below is the previous result, from Stages 2 and 3:

<>Copy
<img _ngcontent-rta-c2="" alt="main banner with image of pizza" width="1920" height="1080" loading="lazy" fetchpriority="auto" src="<https://image-optimization-app.vercel.app/assets/pizza-hero.jpg>">

and here is our new result, from Stage 4:

<>Copy
<img _ngcontent-ttw-c2="" alt="main banner with image of pizza" fill="" priority="" sizes="100vw" loading="eager" fetchpriority="high" src="<https://maciejwojcik.imgix.net/pizza-hero.jpg?auto=format>" srcset="<https://maciejwojcik.imgix.net/pizza-hero.jpg?auto=format&amp;w=640> 640w, <https://maciejwojcik.imgix.net/pizza-hero.jpg?auto=format&amp;w=750> 750w, <https://maciejwojcik.imgix.net/pizza-hero.jpg?auto=format&amp;w=828> 828w, <https://maciejwojcik.imgix.net/pizza-hero.jpg?auto=format&amp;w=1080> 1080w, <https://maciejwojcik.imgix.net/pizza-hero.jpg?auto=format&amp;w=1200> 1200w, <https://maciejwojcik.imgix.net/pizza-hero.jpg?auto=format&amp;w=1920> 1920w, <https://maciejwojcik.imgix.net/pizza-hero.jpg?auto=format&amp;w=2048> 2048w, <https://maciejwojcik.imgix.net/pizza-hero.jpg?auto=format&amp;w=3840> 3840w" style="position: absolute; width: 100%; height: 100%; inset: 0px;">

The new image has so much more! Obviously, we swap the width & height to fill and sizes — so that’s one thing. Next, we have a new attribute — srcset, with a bunch of sources generated for various screens. It wouldn’t be so neat, to write it down for each image manually, right? Lastly, we have new styling, automatically added for fill mode.

Depending on the attributes we provide, the automatic srcset generation differs slightly.

  • when we provide width & height — it means we want a fixed size, but depending on the pixel density we may still need to fetch different resources. It generates a srcset for two density const values: 1 and 2 adding them to the sources list, with the density value and width multiplied by the density.
  • when we provide fill — it means we want a responsive image, and we should specify the sizes attribute (although it’s not required and will assume 100vw by default). It will generate the srcset value, by mapping the breakpoints (Angular specifies them by default, but we can override them if we want) to the sources list, and filtering them before based on the sizes value.
  • when we provide ngSrcset attribute — the directive will generate srcset value based on the ngSrcset and widthif it won’t already be provided in the ngSrcset string.

There is no magic hidden here, everything is implemented intuitively. In my opinion, what makes the directive special is not any complex, indescribable code — but the exceptional developer experience, aiming to help build websites with the best performance, and best user experience, and following all best practices along the way.

Measure LCP
Link to this section

We still need to measure if the change — fetching responsive images, and serving them via CDN actually improves our LCP. The Lighthouse test indicates 17.5s for LCP, which is a decrease of 3.3s compared to the previous step. WebPageTest goes down to 2.7s! Which decreased LCP by 1.8 seconds, making the overall LCP really close to what is described as a good LCP (2.5s).

I did say I will be using the median value from three conducted tests, but if I could choose the best one, it will actually result in 2.5s for LCP, making this a good value for LCP!

Let us take a look at what happened, in the Waterfall graph:

Waterfall View for Stage 4Waterfall View for Stage 4
Waterfall View for Stage 4

Downloading the LCP resource now takes around 1 second, which compared to the previous result above 3 seconds, makes a significant difference. The image we are downloading is smaller, and we are not wasting time to fetch something bigger than it actually would make sense.

Here is the link to the test details if you want to check on your own: https://www.webpagetest.org/result/221204_AiDcTA_5QV/2/details/

Overall LCP improvement
Link to this section

We’ve managed to improve LCP from 6.2s to 2.7s when measuring with WebPageTest, and from 20.8s to 17.5s in the Lighthouse test run on localhost. This is a great result, and as I mentioned we are really close to targeting what is called a good time for LCP (2.5s).

A decreasing trend of LCPA decreasing trend of LCP
A decreasing trend of LCP

Although the localhost app was way slower than the one deployed on Vercel and tested via WebPageTest if we compare the trend it shows similar results. Both tests confirm that the directive does an incredible job with optimizing LCP, as soon as we start optimizing the LCP element. In both cases, we had a small increase in LCP, when we used the default setting, for lazy loading all elements.

Have in mind, that during this entire experiment, we did not use any advanced knowledge about the LCP, performance improvements, and browser images API. We were simply following suggestions from the NgOptimizedDirective, and we went from really bad LCP, to almost perfect.

Additional loaders
Link to this section

In our example, we used the Imgix loader, which is a loader preconfigured by Angular. We were using the following syntax to provide the loader:

<>Copy
providers: [ provideImgixLoader('<https://my.base.url/>'), ]

However, we are not limited to Imgix, and we can use all preconfigured loaders, such as:

Or we could always create a custom one.

Building a custom loader is fairly easy. The function needs to return the resource URL, using config to retrieve a particular asset URL and optional width parameter. So for instance, if we would like to register our own image provider, we can implement it as follows:

<>Copy
providers: [ { provide: IMAGE_LOADER, useValue: (config: ImageLoaderConfig) => { return `https://my-image-provider.com/${config.src}.jpg}`; } }, ],

and if our image provider supports serving different asset versions, based on the width, we may request that using the provider request pattern. For instance, it could look as follows:

<>Copy
providers: [ { provide: IMAGE_LOADER, useValue: (config: ImageLoaderConfig) => { return `https://my-image-provider.com/${config.src}?width="${config.width}"}`; } }, ],

Then in the template, we can use the simplified syntax:

<>Copy
<img ngSrc="pizza.jpg" fill>

Which will result in the following generated src:

<>Copy
https://my-image-provider.com/pizza.jpg?width="720px"

Final Conclusions
Link to this section

The whole article is heavily inspired by the fascinating talk at ng-conf by Kara Erickson. Great kudos to her and the entire team! I’m so impressed by the work they did, to improve the web and user experience, and at the same time make an incredible developer experience. I love the design of the NgOptimizedImage.

While working on this article I’ve learned and discovered a lot, and I want to share with you, the most important outcomes from my point of view.

We should pay attention to optimizing LCP elements. LCP has a significant impact on perceived load speed and this is always something that we should be improving. In our example, we went from an LCP of 6.5 seconds to 2.7 only by optimizing a single element, and we only had to add a few lines of code. If that wasn’t enough, most of those lines were automatically suggested by Angular.

The directive is so comfortable to use, it doesn’t require much “optimization” knowledge from the developers, and ensures good practices by the design. The directive does that through automatic code generation, warnings with suggestions, and errors — when we are clearly doing something not right.

I highly recommend giving the NgOptimizedImage a try in your project, it does all the amazing things we need for our images. It is perfectly tested and maintained and there will be new things coming in.

My last thought is to keep in mind that we should often measure the Core Web Vitals in our applications and enhance them to make the best user experience and improve the web.

Thank you for reading!

Comments (0)

Be the first to leave a comment

Share

About the author

author_image

Hey, I'm a frontend developer, passionate about good design for both the code and UX/UI side of things. I am mainly involved in Angular, rxjs, typescript subjects.

author_image

About the author

Maciej Wojcik

Hey, I'm a frontend developer, passionate about good design for both the code and UX/UI side of things. I am mainly involved in Angular, rxjs, typescript subjects.

About the author

author_image

Hey, I'm a frontend developer, passionate about good design for both the code and UX/UI side of things. I am mainly involved in Angular, rxjs, typescript subjects.

Looking for a JS job?
Job logo
Fullstack (Angular, Node.js) Developer

Nextian Corp.

Worldwide
Remote
$84k - $107k
Job logo
Application Developer (Angular)

Karsun Solutions, LLC

Worldwide
Remote
$104k - $132k
Job logo
Angular Developer

Ryan Consulting Group

Worldwide
Remote
$77k - $80k
More jobs

Featured articles