La imagen del hero, de principio a fin
El recorrido completo de la imagen que decide tu LCP: dónde vive, cómo se declara, con qué cabeceras se sirve, y el guion que verifica los once puntos de una vez.
Las cuatro lecciones anteriores diseccionan el problema por tramos. Esta lo recompone: una sola imagen, desde la decisión de en qué dominio vive hasta el fotograma en que aparece, con cada decisión justificada por la subparte del LCP que mejora. Es la lista que conviene tener delante cuando alguien pregunta qué falta por hacer.
- Recorrer los once puntos de la cadena y asociar cada uno a su subparte.
- Escribir el marcado completo de un hero correcto, con sus cabeceras HTTP.
- Resolver el caso del hero que tiene que ser una imagen de fondo de CSS.
- Verificar los once puntos con un guion en lugar de a ojo.
Los once puntos
Dónde vive.
- Mismo origen que el documento. Ahorra la cadena de establecimiento entera y reutiliza una conexión con la ventana de congestión ya crecida. Afecta al retraso de carga. Si es imposible, preconexión con el modo de credenciales correcto.
- Cabeceras de caché largas con nombre versionado. En la segunda visita la duración de la carga baja a cero. Lo vimos en immutable y stale-while-revalidate.
- Cabecera
Timing-Allow-Originsi es de otro origen. Sin ella no puedes medir el desglose y trabajarás a ciegas.
Qué se sirve.
- Formato moderno con reserva. Es el criterio de elegir formato por tipo, y afecta a la duración de la carga.
- Tamaño intrínseco ajustado al hueco real. Servir 4.000 píxeles de ancho para un hueco de 800 no da ninguna ventaja en la métrica, porque el LCP compite con el menor entre el tamaño visible y el intrínseco. Solo cuesta bytes.
- Compresión comprobada con una métrica perceptual, no con el valor de calidad por defecto del exportador.
Cómo se declara.
- Visible para el escáner en el HTML inicial. Un elemento de imagen con su
src, presente en la respuesta del servidor, sin JavaScript de por medio. fetchpriority="high". Arranca en prioridad alta sin esperar al layout. Afecta al retraso de carga.- Nunca carga diferida. Ni con el atributo nativo ni con una librería.
widthyheightdeclarados. El navegador reserva el hueco antes de tener la imagen y no hay desplazamiento de contenido.
Qué no la bloquea.
- CSS bloqueante más pequeño que la propia imagen, sin fragmento antiparpadeo que oculte el documento, y sin animación de opacidad desde cero.
El marcado completo
Con selección por formato y por ancho, prioridad alta y dimensiones declaradas:
<picture>
<source
type="image/avif"
srcset="/img/hero-800.avif 800w, /img/hero-1600.avif 1600w, /img/hero-2400.avif 2400w"
sizes="(max-width: 900px) 100vw, 900px">
<source
type="image/webp"
srcset="/img/hero-800.webp 800w, /img/hero-1600.webp 1600w, /img/hero-2400.webp 2400w"
sizes="(max-width: 900px) 100vw, 900px">
<img
src="/img/hero-1600.jpg"
alt="Vista aerea del puerto al amanecer"
width="1600" height="900"
fetchpriority="high"
decoding="async">
</picture>
Cinco decisiones que merecen justificación.
No hay precarga. El escáner ve las fuentes del elemento adaptativo y el src del elemento de imagen. Una precarga aquí sería redundante y competiría con el CSS, como vimos en preload.
No hay carga diferida. Es la primera imagen de la ventana.
El texto alternativo describe la imagen. Un hero con contenido no es decorativo. La cadena vacía se reserva para imágenes que no aportan información.
Las dimensiones son las de la fuente más usada, y definen la relación de aspecto que el navegador aplicará a todas.
El orden de las fuentes va del formato más eficiente al menos, porque la evaluación se detiene en la primera que el navegador entiende. Es el mecanismo de el elemento picture.
Las cabeceras
La respuesta de la imagen:
HTTP/2 200
content-type: image/avif
content-length: 61204
cache-control: public, max-age=31536000, immutable
timing-allow-origin: *
vary: Accept
immutable solo es correcto si la URL cambia cuando cambia el contenido; con un nombre estable como /img/hero.avif, un año de caché inmutable significa que no podrás actualizar esa imagen durante un año. La cabecera Vary solo hace falta si estás negociando el formato por cabecera en lugar de con el elemento adaptativo.
Y, si tu servidor tarda en generar el HTML, la pista temprana para el documento:
HTTP/1.1 103 Early Hints
Link: </css/critico.css>; rel=preload; as=style
Fíjate en que la pista temprana es para el CSS, no para la imagen. La imagen ya la descubre el escáner en cuanto llega el HTML; el CSS también, pero el CSS bloquea el renderizado y por tanto adelantarlo mueve el retraso de renderizado, que es el tramo que suele estar inflado.
El hero que tiene que ser un fondo de CSS
A veces el diseño lo exige: un degradado superpuesto, un recorte que depende del punto de interés, un patrón que se repite. El arreglo completo tiene tres piezas.
<head>
<!-- 1. Precarga con tipo y prioridad, ANTES de la hoja de estilos. -->
<link rel="preload" as="image"
imagesrcset="/img/hero-800.avif 800w, /img/hero-1600.avif 1600w"
imagesizes="100vw"
type="image/avif"
fetchpriority="high">
<link rel="stylesheet" href="/css/critico.css">
</head>
/* 2. La misma seleccion por ancho que la precarga, con image-set. */
.hero {
background-image: image-set(
url('/img/hero-800.avif') 1x,
url('/img/hero-1600.avif') 2x
);
background-size: cover;
background-position: center;
min-height: 60svh;
}
# 3. Verificar que la imagen se descarga UNA vez.
# Dos descargas significa que la precarga y el CSS no coinciden.
El tercer punto no es opcional. Cuando la selección de la precarga y la del CSS divergen, el navegador descarga dos archivos distintos de la misma imagen, y has convertido una optimización en el doble de bytes durante la ventana crítica. Es el modo de fallo número dos de cuándo las pistas hacen daño.
La razón que más veces justifica un fondo de CSS es que en móvil hay que recortar la imagen por otro sitio. No hace falta: object-position sobre un elemento de imagen hace exactamente lo mismo que background-position, y se puede cambiar por consulta de medios. Con eso conservas el descubrimiento temprano, las dimensiones declaradas y el control de prioridad, que un fondo de CSS no te da.
.hero__img { width: 100%; height: 60svh; object-fit: cover; object-position: 70% 40%; }
@media (max-width: 700px) { .hero__img { object-position: 55% 30%; } }El guion de verificación
Pégalo en la consola con la caché desactivada y la red limitada, y sin tocar la página hasta que termine:
new PerformanceObserver((lista) => {
const e = lista.getEntries();
const entrada = e[e.length - 1];
const el = entrada.element;
if (!el) return;
const nav = performance.getEntriesByType('navigation')[0];
const t0 = nav.activationStart || 0;
const recursos = performance.getEntriesByType('resource')
.sort((a, b) => a.startTime - b.startTime);
const r = recursos.find((x) => x.name === entrada.url);
const copias = recursos.filter((x) => x.name === entrada.url).length;
const css = recursos.filter((x) => x.initiatorType === 'link' && x.name.endsWith('.css'));
const cssBytes = css.reduce((s, x) => s + (x.encodedBodySize || 0), 0);
const check = (ok, texto) => console.log(ok ? '[ok]' : '[X] ', texto);
console.log('Elemento LCP:', el.tagName, '|', Math.round(entrada.startTime - t0) + 'ms');
check(!!entrada.url, 'Es un recurso de red y no un bloque de texto');
check(el.getAttribute('loading') !== 'lazy', 'Sin carga diferida');
check(el.getAttribute('fetchpriority') === 'high', 'Con fetchpriority alto');
check(!!el.getAttribute('width') && !!el.getAttribute('height'), 'Con width y height');
check(copias === 1, 'Descargado una sola vez, no ' + copias);
check(r ? new URL(r.name).origin === location.origin : false, 'Servido desde el mismo origen');
check(r ? r.responseEnd > 0 : false, 'Con tiempos expuestos, hay Timing-Allow-Origin');
check(r ? r.startTime - recursos[0].startTime < 100 : false,
'Arranca junto al primer recurso');
check(r ? (r.encodedBodySize || 0) > cssBytes : false,
'La imagen pesa mas que el CSS bloqueante, ' +
Math.round((r?.encodedBodySize || 0) / 1024) + ' KB frente a ' +
Math.round(cssBytes / 1024) + ' KB');
check(el.getBoundingClientRect().width * devicePixelRatio >= (el.naturalWidth || 0) * 0.5,
'Sin sobredimensionar en exceso respecto al hueco');
}).observe({ type: 'largest-contentful-paint', buffered: true });
La penúltima comprobación es la del criterio de tamaño relativo: si el CSS bloqueante pesa más que la imagen, la imagen no es tu cuello de botella. La última avisa de que estás sirviendo una imagen mucho mayor de lo que el hueco necesita, que solo cuesta bytes porque el LCP compite con el menor de los dos tamaños.
Toda esta lección optimiza el peor caso: usuario nuevo, caché vacía, red mala. Es el caso correcto por el que empezar porque es el que la métrica pública mide con más peso y el que decide la primera impresión. Pero antes de invertir una semana más en recortar veinte kilobytes del hero, merece la pena mirar un dato que casi nadie mira: qué proporción de tus cargas son primeras visitas. Se saca de tu propia instrumentación cruzando el desglose con si el recurso vino de caché, cosa que la API de recursos te dice porque transferSize vale cero o casi cero cuando la respuesta salió de disco. En un sitio con visitas recurrentes, la mitad de las cargas puede tener la imagen ya en el dispositivo, y para esas cargas la duración de la carga es cero y todo el LCP está en el TTFB y en el retraso de renderizado. Optimizar el formato de la imagen no mueve un milímetro esa mitad de tu tráfico, y sí lo mueve reducir el CSS bloqueante o el tiempo de servidor. La consecuencia de método es que el desglose hay que segmentarlo también por primera visita frente a visita recurrente, y no solo por plantilla y dispositivo: son dos perfiles con dos cuellos de botella distintos que la media mezcla en uno que no existe. Y la consecuencia de estrategia es que la caché bien puesta, con nombres versionados y un año de vida, no es una optimización menor que se hace al final: es la que decide cuál de los dos perfiles tiene la mayoría de tus usuarios.