Cómo reduje mi LCP en un 70%

Aprende métodos avanzados para mejorar los Core Web Vitals

Arjen Karel Core Web Vitals Consultant
Arjen Karel - linkedin
Last update: 2024-11-27

Mejorando las métricas de LCP con web workers y carga de imágenes en 2 etapas

La mayoría de las veces, un elemento de imagen grande en el viewport visible se convertirá en el elemento Largest Contentful Paint. Incluso después de aplicar todas las mejores prácticas de Lighthouse como redimensionamiento de imágenes, compresión de imágenes, conversión a WebP y precarga del elemento LCP, tu Largest Contentful Paint aún podría no pasar los Core Web Vitals.

La única forma de solucionar esto es usando tácticas más avanzadas como la carga en 2 etapas y ejecutar hilos en tu página con webworkers para liberar recursos en el hilo principal.

En este artículo, mostraré cómo mejorar aún más el Largest Contentful Paint.

Por qué debería precargar la imagen del Largest Contentful Paint

Algo de contexto

Soy un experto en velocidad de página y mi sitio web es mi carta de presentación. En mi página de inicio proclamo con orgullo que mi sitio es el más rápido del mundo. Por eso necesito que mi página cargue lo más rápido posible y exprimir cada gota de velocidad de mi sitio.

Las técnicas que te mostraré hoy podrían no ser viables para tu sitio promedio (WordPress) sin el apoyo de un equipo de desarrollo dedicado y talentoso. Si no puedes duplicar esta técnica en tu propio sitio, te animo a que leas el artículo y aprendas cómo pienso sobre la velocidad de página y cuáles son mis consideraciones.

El problema: imágenes grandes en el viewport visible

Una imagen grande en el viewport visible a menudo se convertirá en el elemento Largest Contentful Paint. Frecuentemente sucede que esta imagen LCP no pasa los Core Web Vitals. Veo resultados como estos a diario.

mal LCP con imagen grande

Hay varias formas de asegurar que este elemento aparezca en pantalla rápidamente:

  1. Precargar el elemento LCP. Precargar la imagen LCP asegurará que esta imagen esté disponible para el navegador lo antes posible.
  2. Usar imágenes responsivas. Asegúrate de no servir imágenes de tamaño escritorio a dispositivos móviles.
  3. Comprimir tus imágenes. La compresión de imágenes podría reducir drásticamente el tamaño de la imagen.
  4. Usar formatos de imagen de nueva generación. Los formatos de imagen de nueva generación como WebP superan a los formatos antiguos como JPEG y PNG en casi todos los casos.
  5. Minimizar la ruta crítica de renderizado. Elimina todos los recursos que bloquean el renderizado como JavaScripts y hojas de estilo que podrían retrasar el LCP.

Desafortunadamente, a pesar de todas estas optimizaciones, en algunos casos, las métricas de LCP aún podrían no pasar la auditoría de Core Web Vitals. ¿Por qué? El tamaño de la imagen por sí solo es suficiente para retrasar el LCP.

La solución: carga en 2 etapas y web workers

La solución que implementé (después de optimizar todos los demás problemas en mi sitio) es la carga de imágenes en 2 etapas.

La idea es simple: en el primer renderizado mostrar una imagen de baja calidad con las mismas dimensiones exactas que la imagen final de alta calidad. Inmediatamente después de que esa imagen se muestre, iniciar el proceso que intercambia la imagen de baja calidad por una de alta calidad.

Una implementación muy básica podría verse algo así: Primero añadir un listener de evento load a una imagen. Cuando la imagen carga, ese mismo listener se desvincula y el src de la imagen se intercambia por la imagen final de alta calidad.

<img 
     width="100" 
     height="100" 
     alt="algún texto alternativo" 
     src="lq.webp" 
     onload="this.onload=null;this.src='hq.webp'"
>

Etapa 1: webp de baja calidad 3-5kb

Etapa 2: webp de alta calidad 20-40kb

Esto puede parecer bastante simple (y lo es), pero intercambiar una gran cantidad de imágenes temprano en el proceso de renderizado causará demasiada actividad en el hilo principal y afectará otras métricas de Core Web Vitals.

Es por eso que elegí descargar parte del trabajo a un web worker. Un web worker se ejecuta en un nuevo hilo y no tiene acceso real a la página actual. La comunicación entre el web worker y la página se realiza a través de un sistema de mensajería. La ventaja obvia es que no estamos usando el hilo principal de la página, estamos liberando recursos allí. La desventaja es que usar un web worker puede ser un poco engorroso.

El proceso en sí no es tan difícil. Una vez que se ha disparado el evento DomContentLoaded, recopilo todas las imágenes de la página. Si una imagen ya se ha cargado, la intercambiaré inmediatamente. Si no se ha cargado (porque la imagen podría cargarse de forma lazy), adjuntaré un listener de evento que intercambia la imagen después de la carga lazy.

El resultado: espectacular

El código para carga de LCP en 2 etapas a través de un web worker

Aquí está el código que uso para acelerar mi LCP a través de carga en 2 etapas y un web worker. El código en la página principal llama a un webworker que buscará las imágenes. El webworker pasa el resultado como un blob a la página principal. Al recibir el blob, la imagen se intercambia.

Worker.js

El worker tiene un solo trabajo. Escucha mensajes. Un mensaje contendrá una url de imagen y un id único de imagen. Primero transformará la url de la imagen a la versión de alta calidad. En mi caso, cambiando /lq a /resize en la url de la imagen. El worker luego buscará la imagen de alta calidad, la obtendrá, creará un blob y luego devolverá el blob de la imagen junto con el id único.
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

El script.js se ejecutará como un script normal en la página web activa. El script primero carga el worker. Luego recorrerá todas las imágenes de una página. Esto sucede temprano en el proceso de renderizado. Una imagen podría ya estar cargada o no. Si una imagen de baja calidad ya está cargada, llamará al proceso de intercambio inmediatamente. Si aún no está cargada, adjuntará un listener al evento de carga de la imagen que inicia el proceso de intercambio tan pronto como esa imagen se cargue.
Cuando una imagen se carga, se genera un id único para esa imagen. Esto me permite encontrar fácilmente la imagen en la página de nuevo (recuerda, el worker no tiene acceso al DOM así que no puedo enviar el nodo DOM de la imagen).
La URL de la imagen y el id único se envían entonces al worker.
Cuando el worker ha obtenido la imagen, se envía de vuelta al script como un blob. El script finalmente intercambia la URL de la imagen antigua por la URL del blob que fue creado por el 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 }
                )
        })
})

Puntuación de Core Web Vitals con imagen LCP precargada

Lab data is not enough.

I analyze your field data to find the edge cases failing your user experience.

Analyze My Data >>

  • Real User Data
  • Edge Case Detection
  • UX Focused
Cómo reduje mi LCP en un 70% Core Web Vitals Cómo reduje mi LCP en un 70%