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: 2026-03-04

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 redimensionar imágenes, comprimir imágenes, convertir a WebP y precargar el elemento LCP, tu Largest Contentful Paint aún podría no pasar los Core Web Vitals.

La única forma de solucionarlo es utilizando tácticas más avanzadas como la carga en 2 etapas y ejecutar tu página con Web Workers para liberar recursos en el hilo principal.

Why should I preload the largest contentful paint image

Última revisión por Arjen Karel en marzo de 2026

Algo de contexto

Soy un especialista en pagespeed y mi sitio web es mi carta de presentación. En mi página de inicio afirmo 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 rendimiento 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 igualmente a leer el artículo y aprender cómo pienso sobre el pagespeed 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 ocurre que esta imagen LCP no pasa los Core Web Vitals. Veo resultados como estos a diario.

bad LCP with large image

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. Combínalo con fetchpriority="high" para indicarle al navegador que priorice esta imagen sobre otros recursos.
  2. Usa imágenes responsivas. Asegúrate de no estar sirviendo imágenes de tamaño escritorio a dispositivos móviles.
  3. Comprime tus imágenes. La compresión de imágenes podría reducir drásticamente el tamaño de la imagen.
  4. Usa 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. Minimiza la ruta crítica de renderizado. Elimina todos los recursos que bloquean el renderizado como JavaScript 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 la fase de duración de carga del recurso del 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 así: Primero añade un event listener de carga a una imagen. Cuando la imagen se carga, ese mismo event listener se desvincula y el src de la imagen se intercambia por la imagen final de alta calidad.

<img
     width="100"
     height="100"
     alt="some alt text"
     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 suficientemente simple (y lo es), pero intercambiar una gran cantidad de imágenes al inicio del proceso de renderizado causará demasiada actividad en el hilo principal y afectará otras métricas de Core Web Vitals.

Por eso 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 el evento DOMContentLoaded se ha disparado, recolecto 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 hacer lazy load), adjuntaré un event listener que intercambia la imagen después del lazy load.

Una advertencia importante: el navegador trata cada intercambio de imagen como un nuevo candidato a LCP. Si tu intercambio de imagen de alta calidad ocurre después de 2,5 segundos, el LCP se medirá en el momento del intercambio, no en el momento del placeholder. Por eso es importante que el Web Worker obtenga e intercambie la imagen lo más rápido posible.

El resultado: espectacular.

El código para la carga LCP en 2 etapas a través de un Web Worker

Aquí está el código que uso para acelerar mi LCP mediante la carga en 2 etapas y un Web Worker. El código en la página principal llama a un Web Worker que obtendrá las imágenes. El Web Worker pasa el resultado como un blob a la página principal. Al recibir el blob, la imagen se intercambia.

Worker.js

El worker tiene una sola tarea. Escucha mensajes. Un mensaje contendrá una URL de imagen y un id único. Primero transformará la URL de la imagen a la versión de alta calidad. En mi caso, cambiando /lq por /resize en la URL de la imagen. El worker luego obtendrá la imagen de alta calidad, creará un blob y 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 la página. Esto sucede al inicio del proceso de renderizado. Una imagen puede estar ya 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 luego al worker. Cuando el worker ha obtenido la imagen, se envía de vuelta al script como un blob. El script finalmente intercambia la antigua URL de la imagen por la URL del blob que fue creada 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 }
                )
        })
})

Para verificar la mejora del LCP en el campo, usa Real User Monitoring para rastrear cómo tus visitantes reales experimentan la página. Las herramientas de laboratorio como Lighthouse mostrarán la mejora, pero los datos de campo de usuarios reales en conexiones variadas son lo que cuenta para pasar los Core Web Vitals.

About the author

Arjen Karel is a web performance consultant and the creator of CoreDash, a Real User Monitoring platform that tracks Core Web Vitals data across hundreds of sites. He also built the Core Web Vitals Visualizer Chrome extension. He has helped clients achieve passing Core Web Vitals scores on over 925,000 mobile URLs.

I have done this before at your scale.

Complex platforms, large dev teams, legacy code. I join your team as a specialist, run the performance track, and hand it back in a state you can maintain.

Discuss Your Situation
Cómo reduje mi LCP en un 70%Core Web Vitals Cómo reduje mi LCP en un 70%