How I lowered my LCP by 70%
Learn advanced methods on improving the Core Web Vitals
Improving the LCP metrics with web-workers and 2-stage image loading
Most of the times a large image element in the visible viewport will become the Largest Contentful Paint element. Even after applying all the Lighthouse best-practices like image resizing, image compression, WebP conversion and preloading the LCP element your Largest Contentful Paint still might not pass the Core Web Vitals.
The only way to fix this is by using more advanced tactics like 2-stage loading and threading your page with webworkers to free up resources on the main thread.
In this article, I'll show how to further improve the largest contentful paint.
Some background
I am a pagespeed guy and my website is my showcase. On my homepage I proudly claim my site to be the fastest site in the world. That is why I need my page to load as fast as possible and squeeze every drop of pagespeed out of my site.
The techniques I will show you today might not be viable for your average (WordPress) site without the support a dedicated and talented dev team. If you cannot duplicate this technique on your own site I still encourage you to read the article and learn how I think about pagespeed and what my considerations are.
The problem: large images in the visible viewport
A large image in the visible viewport will often become the Largest Contentful Paint element. It often happens that this LCP image does not pass the Core Web Vitals. I see results like this on a daily base.
There are a number of ways to make sure this element appears on screen fast:
- Preload the LCP element. Preloading the LCP image will make sure this image is available to the browser as early as possible.
- Use responsive images. Make sure you are not serving desktop-sized images to mobile devices.
- Compress your images. Image compression could drastically reduce the size of the image
- Use next gen image formats. Next gen image formats like WebP outperform older formats like JPEG and PNG in almost all cases.
- Minimize the critical rendering path. Eliminate all render blocking resources like JavaScripts and Style-sheets that might delay the LCP.
Unfortunately despite of all these optimizations, in some cases, the LCP metrics might still not pass the Core Web Vitals audit. Why? The size of the image alone is enough to delay the LCP.
The solution: 2-stage loading and web workers
The solution I implemented (after optimizing all other issues on my site) is 2-stage image loading.
The idea is simple: on first render show a low quality image with the same exact dimensions as the final high-quality image. Immediately after that image is displayed start the process that swaps the low quality image for a high quality image.
A very basic implementation might look something like this: First add a load event listener to an image. When the image loads that same event listener detaches itself and the scr of the image is swapped for the final, high quality image.
<img width="100" height="100" alt="some alt text" src="lq.webp" onload="this.onload=null;this.src='hq.webp'" >
Stage 1: low quality webp 3-5kb
Stage 2: high quality webp 20-40kb
This might seem simple enough (and it is) but swapping a large number of images early on in the rendering process will cause too much activity on the main thread and affect other Core Web Vitals metrics.
That Is why I chose to offload some of the work to a web worker. A web worker runs in a new thread and has no real access to the current page. Communication between the web worker and the page is done though a messaging system. The obvious advantage is that we are not using the page it's main thread itself, we are freeing resources there. The disadvantage is that using a web worker can be a little cumbersome.
The process itself is not that difficult. Once the DomContentLoaded event has been fired I collect all the images on the page. If an image has been loaded I will immediately swap it. If it has not been loaded (because the image might lazy load) I will attach an event listener that swaps the image after lazy load.
The result: spectaculair
The Code for 2-stage LCP loading though a web worker
Here is the code that I use to speed up my LCP though 2-stage loading and a web worker. The code on the mail page calls a webworker that will fetch the images. The webworker passes the result as a blob to the main page. On receiving the blob the image is swapped.
Worker.js
self.addEventListener('message', async event => { const newimageURL = event.data.src.replace("/lq-","/resize-"); const response = await fetch(newimageURL) const blob = await response.blob() // Send the image data to the UI thread! self.postMessage({ uid: event.data.uid, blob: blob, }) })
Script.js
The script.js will run as a normal script on the active webpage. The script first loads the worker. Then it will cycle though all the images on a page. This happend early on in the rendering process. An image might already be loaded and it might not. If a low quality image is already loaded it will call the swap process immediately. If it is not yet loaded it will attach a listener to the image load event that starts the swap process as soon as that image is loaded..
When an image is loaded a unique id is generated for that image. This allows me to easily find the image on the page again (remember, the worker has not access to the dom so I cannot send the image DOM Node).
The image URL and unique id are then send to the worker.
When the worker has fetched the image it is send back to the script as a blob. The script eventuality swaps the old image URL for the blob URL that was created by the web worker.
var myWorker = new Worker('/path-to/worker.js'); // send a message to worker const sendMessage = (img) => { // uid makes it easier to find the image var uid = create_UID(); // set data-uid on image element img.dataset.uid = uid; // send message to worker myWorker.postMessage({ src: img.src, uid: uid }); }; // generate the uid const create_UID = () => { var dt = new Date().getTime(); var uuid = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) { var r = (new Date().getTime() + Math.random() * 16) % 16 | 0; dt = Math.floor(dt / 16); return (c == 'x' ? r : (r & 0x3 | 0x8)).toString(16); }); return uid; } // when we get a result from the worker myWorker.addEventListener('message', event => { // Grab the message data from the event const imageData = event.data // Get the original element for this image const imageElement = document.querySelectorAll("img[data-uid='" + imageData.uid + "']"); // We can use the `Blob` as an image source! We just need to convert it // to an object URL first const objectURL = URL.createObjectURL(imageData.blob) // Once the image is loaded, we'll want to do some extra cleanup imageElement.onload = () => { URL.revokeObjectURL(objectURL) } imageElement[0].setAttribute('src', objectURL) }) // get all images document.addEventListener("DOMContentLoaded", () => { document.querySelectorAll('img[loading="lazy"]').forEach( img => { // image is already visible? img.complete ? // swap immediately sendMessage(img) : // swap on load img.addEventListener( "load", i => { sendMessage(img) }, { once: true } ) }) })
Core Web Vitals Score with LCP image preloaded
Stop Guessing, Start Measuring
I use Core/Dash to optimize my clients sites. So Should You.
- Easy set up.
- Over 5000 happy clients
- Free trial, No strings attached