Optimising images for better LCP web vitals scores

Javier Villanueva
ITNEXT
Published in
8 min readJan 4, 2021

--

Photo by Soragrit Wongsa

Images are some of the most common types of content used on the web, they’re often used to attract user’s attention and they are usually the biggest element on-screen above the fold (think of banners, hero images, product images, etc) so it’s very important we do the best we can to make them display as fast as possible to maintain a good user experience.

Taking a look at Google’s Web Vitals there’s an important metric that images can affect and that is “Largest Contentful Paint” (LCP), considering this alone makes up to 25% of the total Lighthouse score it is one metric that we can’t afford to ignore.

The Largest Contentful Paint (LCP) metric reports the render time of the largest image or text block visible within the viewport.

If our largest element above the fold is an image (which is very likely specially on e-commerce websites) then optimising this image will make it render quicker and improve our Lighthouse scores at the same time. So, I’d like to share a few tips on how to improve LCP with some performance measurements to back them up.

The Test Subject

I’ll use the demo from my “Javascript sliders will kill your website performance” article because it’s very simple and it shows a layout that’s common in e-commerce product pages so hopefully you will be able to relate.

Initial demo

Here are the initial numbers, I included “First Contentful Paint” (FCP) as well just for reference as I didn’t want my optimisations on LCP to negatively impact FCP:

FCP: 1.17s
LCP: 1.59s
Page Size: 67.8kb

The performance tests were made using an iPhone 8 screen simulating a 3G connection (1.6 Mbps / 768 Kbps 300ms RTT)

Use the right image for the screen size

This is one that’s sometimes very obvious but often overlooked, you won’t believe the amount of sites out there that ship images that are 3+ times bigger than they should because they just resize the desktop-sized images down.

As a rule of thumb try not to use images that are more than 2 times bigger than the size they are going to be displayed as, if you don’t mind losing some quality on HiDPI devices you can even go smaller than that. Use the srcset <img> attribute or the picture element to display the appropriate image for the screen size.

In my demo the product image is going to be displayed at about 375px wide so I chose to use an 750px wide image so it looks good on retina screens and modern devices.

Let’s see how our performance timeline looks right now:

Initial performance timeline

We seem to be loading more images than we need right now so let’s try our next optimisation.

Lazy load images

This is another advice that you hear often, what is not that common is that nowadays you can achieve this without any javascript by simply using the loading="lazy" attribute in our images.

The loading attribute support is pretty good and you can still selectively include a javascript-based lazy loader if you REALLY need IE/Safari support.

After adding lazy loading support to our images our metrics look like this:

FCP: 1.17s
LCP: 1.98s (+24%)
Page Size: 50.7kb (-25%)

Wait, why is our LCP time worse!? Page size went down which is not surprising since we’re loading one image less but let’s have a look at our timeline to understand the increased LCP time.

Performance timeline after lazy loading

This is one gotcha that comes from lazy loading: browsers will decrease the priority of lazily loaded images which is great for images out of the viewport but not so much if it affects our LCP element.

The solution here is simple, avoid lazy loading images if they are above the fold. In this case we just need to remove the loading="lazy" attribute from the first product image.

Performance timeline after selective lazy loading

By doing this we managed to make the browser load our LCP element sooner while still keeping the page size improvements:

FCP: 1.17s
LCP: 1.33s (-16%)
Page Size: 50.7kb (-25%)

Decode images asynchronously (maybe)

Another not-so-known optimisation trick is to use the decode="async" image attribute, this will tell the browser to decode the image asynchronously without blocking the main thread.

However for this demo it didn’t seem to make much difference, here’s how the timeline looks before and after the change:

Before decode=”async”
After decode=”async”

I didn’t notice any difference in the main thread, it still seems to be doing the same amount of work with and without the change even with the extra rasterizer thread that gets created. Maybe it’s because the demo doesn’t load that many images but I wanted to bring this up still just in case it helps anyone else, it doesn’t negatively impact our metrics so I chose to keep it for now.

Use modern image formats

So far I’ve been using JPEG’s in the demo to load product images but we can do much better. By converting images to WebP we can drastically decrease image size without any perceivable loss in quality so let’s try that first.

FCP: 1.17s
LCP: 1.26s (-20%)
Page Size: 35kb (-48%)

Our page size is almost half of how it originally used to be and our LCP times keep getting better, however if you like living on the edge we can try a newer image format: AVIF.

AVIF is modern image format based on the AV1 video format. AVIF generally has better compression than WebP, JPEG, PNG and GIF and is designed to supersede them.

FCP: 1.17s
LCP: 1.26s (-20%)
Page Size: 24.2kb (-64%)

Our FCP and LCP times are still the same (maybe because of TCP slow start?) but our page size is considerably smaller. The main downside of AVIF is that it’s not widely supported yet but we can use the <picture> element to display the correct image based on browser support.

Prefer AVIF if not WEBP if not JPG

There’s another downside of WEBP and AVIF, I’ll show the JPG version and AVIF version side by side to see if you can spot the difference:

Left: JPEG version - Right: AVIF version

In case you didn’t see it, neither WEBP or AVIF support progressive loading so with JPEGs we get a lower quality preview of the image as it loads instead of it showing all at once.

If you want to achieve a similar effect you’ll need to display the lower quality version yourself, for the demo I chose to use SQIP to display a lower quality SVG version of the main image applied as its background so it will get covered after the product image loads.

“Progressively” rendering AVIF images

From my testing this doesn’t affect the LCP times, I’m not sure if this is intentional or not but Chrome will still wait until the image is fully loaded to report that the largest contentful element has loaded, however it can help improve “perceived” performance which is also a good user experience.

Same as before, it doesn’t seem to negatively impact the metrics in the demo so I decided to keep it.

HTTP2 Server Push

So far we’ve been making improvements in the frontend to get out LCP times down but this for this last tip we’ll need to make a few tweaks to the way the server responds to HTTP requests.

Before doing anything let’s have a look at our biggest bottleneck so far:

Performance timeline analysis

First: The HTML document needs to be downloaded and parsed, from top to bottom line by line so the browser knows what resources need to be downloaded next and with what priority. Then, it figures out that the CSS, logo and first product images need to be loaded first so it goes and fetches them but only after the first step is done.

HTTP2 Server Push allows us to include hints to the browser about what resources to load as part of the response headers, so we can start loading them before the HTML has been parsed.

Modern web servers like nginx and Apache support this out of the box, the implementation varies slightly but generally they work by adding an extra Link header to the response header with a list of resources that look something like this:

Link: </style.css>; as=style; rel=preload, </image.jpg>; as=image; rel=preload

In a lot of cases this is not straightforward though, if the resources are the same you can get away by hardcoding them (eg: logos, common styles, etc) but if they change (like in the case of product images) you need a way of knowing exactly what images are going to be loaded depending on the page.

You may also end up pushing resources that are already cached by the browser forcing it to re-download them again and ironically hurting site performance.

I personally like Cloudflare’s HTTP2 Server Push Service, they help handle the extra complexity of browser cache but you are still required to send the resources via an extra Link header. I added this to the demo and the results are very impressive:

FCP: 0.70s (-40%)
LCP: 0.86s (-35%)
Page Size: 24.2kb (-64%)

Performance timeline analysis after HTTP2 Server Push

I preloaded the styles, logo and main product image with HTTP2 Server Push and as you can see they are loaded much sooner (even our FCP times were cut almost in half).

Sometimes it’s hard to appreciate numbers by themselves so for perspective here’s a comparison of all the optimisations done until now as a filmstrip view.

Filmstrip view of optimisations

Key takeaways

  1. At the very minimum lazy load images with appropriate sizes and formats.
  2. HTTP2 Server Push can improve FCP / LCP times but implementation can be tricky.
  3. Measure and profile EVERY change you make, you can’t improve what you don’t measure.

I hope these tips help you improve your website performance and Lighthouse scores, let me know if there’s anything missing that you’ve used to deal with FCP / LCP times. 👋🏽

--

--

Lead Developer at Media Lounge · 6x Magento Certified & Full Stack Developer · E-Commerce Specialist · Currently @ Bournemouth, UK