How I lowered the LCP by 70% - Web Workers and 2-stage loading

Arjen Karel

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

Why should I preload the largest contentful paint image

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.

bad LCP with large image

There are a number of ways to make sure this element appears on screen fast:

  1. Preload the LCP element. Preloading the LCP image will make sure this image is available to the browser as early as possible.
  2. Use responsive images. Make sure you are not serving desktop-sized images to mobile devices.
  3. Compress your images. Image compression could drastically reduce the size of the image
  4. Use next gen image formats. Next gen image formats like WebP outperform older formats like JPEG and PNG in almost all cases.
  5. 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. 

     alt="some alt text" 

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.


The worker has one job. It listens to messages. A message will contain an image url and a image unique id. First it will transform the image url to the high quality version. In my case by changing /lq to /resize in the image url. The worker will then fetch the high quality image, fetch it, create a blob and then return the image blob along with the unique id.
self.addEventListener('message', async event => {
    const newimageURL ="/lq-","/resize-");

    const response = await fetch(newimageURL)
    const blob = await response.blob()

    // Send the image data to the UI thread!
        blob: blob,


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 =

    // 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 = () => {
    imageElement[0].setAttribute('src', objectURL)

// get all images
document.addEventListener("DOMContentLoaded", () => {
        img => {

            // image is already visible?
            img.complete ?

                // swap immediately
                sendMessage(img) :

                // swap on load
                    "load", i => { sendMessage(img) }, { once: true }

Core Web Vitals Score with LCP image preloaded 

lighthouse 100 score

Let's talk about PageSpeed

Tell me about your pagespeed goals. I am a Core Web Vitals consultant and I help speed up websites.