Optimización de Web Performance: Guía Técnica para LCP y CLS

Técnicas avanzadas para mejorar Core Web Vitals en e-commerce: compresión Brotli, HTTP/3, SSR, placeholders y optimización de imágenes críticas.

Jonathan Valenzuela
Jonathan Valenzuela Head of SEO

Por qué Web Performance es SEO

Google confirmó que las Core Web Vitals son factor de ranking desde 2021. Pero más allá del SEO, la velocidad afecta directamente a conversión: Amazon calculó que cada 100ms de latencia adicional les costaba un 1% en ventas.

Los umbrales actuales de Google son claros:

MétricaBuenoNecesita mejoraPobre
LCP≤2.5s2.5s - 4s>4s
INP≤200ms200ms - 500ms>500ms
CLS≤0.10.1 - 0.25>0.25

En e-commerce, el LCP y CLS son los que más problemas dan. INP suele estar controlado si no abusas de JavaScript.

Compresión: De Gzip a Brotli

La mayoría de sitios usan Gzip para comprimir texto. Funciona, pero Brotli ofrece 15-20% mejor compresión en archivos de texto (HTML, CSS, JS, JSON).

AlgoritmoRatio compresiónVelocidadSoporte
Gzip~70%RápidoUniversal
Brotli~80-85%Más lento en compresión97%+ navegadores

Cómo verificar qué compresión usas

Abre DevTools → Network → selecciona un archivo de texto → Headers → busca content-encoding:

  • gzip = Gzip
  • br = Brotli
  • Vacío = Sin compresión (problema grave)

Implementación

En Nginx:

brotli on;
brotli_comp_level 6;
brotli_types text/plain text/css application/json application/javascript text/xml application/xml;

En CDN (Cloudflare, Akamai, Fastly): suele ser un checkbox en configuración. Akamai requiere habilitarlo explícitamente en la configuración de entrega.

HTTP/3: Reducir latencia de conexión

Con HTTP/2 y TLS 1.3, la negociación de conexión consume aproximadamente 288ms antes de que pueda hacerse la primera petición. HTTP/3 reduce esto a la mitad: ~144ms.

ProtocoloHandshakeMultiplexingHead-of-line blocking
HTTP/1.1TCP + TLS separadosNo
HTTP/2TCP + TLSA nivel TCP
HTTP/3QUIC (integrado)No

HTTP/3 usa QUIC en lugar de TCP, lo que elimina el problema de head-of-line blocking y reduce la latencia inicial. En conexiones móviles o con pérdida de paquetes, la diferencia es más notable.

Cómo verificarlo

En DevTools → Network → columna “Protocol”. Deberías ver h3 para HTTP/3.

Implementación

La mayoría de CDNs modernos soportan HTTP/3:

  • Cloudflare: Activado por defecto
  • Akamai: Checkbox en configuración de propiedad
  • Fastly: Requiere activación manual
  • AWS CloudFront: Soportado desde 2022

Si sirves desde origen sin CDN, necesitas configurar tu servidor web para QUIC, lo cual es más complejo.

CLS: El problema del contenido cargado con JavaScript

El CLS mide cuánto “salta” el contenido mientras carga. El peor escenario: infinite scroll o lazy loading mal implementado donde el contenido aparece y desplaza lo que el usuario estaba leyendo.

Causas comunes de CLS alto

CausaImpacto CLSSolución
Imágenes sin dimensionesAltoAñadir width y height
Anuncios dinámicosMuy altoReservar espacio fijo
Fuentes web (FOUT)Mediofont-display: optional
Contenido inyectado por JSAltoSSR o placeholders
Infinite scrollMuy altoPaginación o SSR

Server-Side Rendering vs Client-Side

Si tu contenido principal se renderiza con JavaScript en el cliente, tienes dos problemas:

  1. LCP se retrasa porque el navegador debe descargar JS, parsearlo, ejecutarlo y luego renderizar
  2. CLS explota porque el espacio reservado no coincide con el contenido final

La solución ideal es Server-Side Rendering (SSR): el HTML llega completo desde el servidor. Frameworks como Next.js, Nuxt, Astro o SvelteKit lo soportan nativamente.

Si SSR no es opción, necesitas placeholders perfectos.

Placeholders que funcionan

Un placeholder efectivo debe:

  1. Ocupar exactamente el mismo espacio que el contenido final
  2. Ser responsive para funcionar en todos los viewports
  3. Usar unidades de viewport para adaptarse sin recalcular
/* Placeholder para una card de producto */
.product-card-placeholder {
  aspect-ratio: 4/3;
  width: 100%;
  background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
  background-size: 200% 100%;
  animation: shimmer 1.5s infinite;
}

@keyframes shimmer {
  0% { background-position: 200% 0; }
  100% { background-position: -200% 0; }
}

El truco está en aspect-ratio: mantiene la proporción sin importar el ancho, y no causa layout shift cuando el contenido real reemplaza al placeholder.

No uses loading="lazy" en imágenes insertadas por JS

Si ya cargas imágenes de forma lazy con JavaScript (como en infinite scroll), añadir loading="lazy" es redundante y contraproducente: estás aplicando lazy sobre lazy.

<!-- MAL: Doble lazy loading -->
<img src="producto.webp" loading="lazy">
<!-- La imagen ya fue insertada por JS cuando era necesaria -->

<!-- BIEN: Sin atributos de carga en imágenes JS -->
<img src="producto.webp">
<!-- El navegador la cargará inmediatamente al estar en el DOM -->

Para imágenes insertadas dinámicamente por JavaScript, mi recomendación es no usar ni loading ni fetchpriority. El navegador ya sabe que la imagen es necesaria (está en el DOM) y actuará correctamente sin intervención.

LCP: No hagas lazy load de imágenes críticas

El LCP suele ser la imagen principal del hero o la imagen de producto más grande. Un error común es aplicar loading="lazy" a todas las imágenes, incluyendo las críticas.

<!-- MAL: Lazy load en imagen LCP -->
<img src="hero.webp" loading="lazy" alt="Hero">

<!-- BIEN: Sin lazy load en imagen LCP -->
<img src="hero.webp" alt="Hero" fetchpriority="high">

Identificar qué imagen es el LCP

  1. DevTools → Performance → grabar carga de página
  2. Buscar el marcador “LCP” en el timeline
  3. Click en el marcador para ver qué elemento es

También puedes usar este CSS temporal para detectar imágenes con lazy load:

img[loading="lazy"] {
  border: 10px solid red !important;
}

Optimización de imagen LCP

TécnicaImpacto
Quitar loading="lazy"Alto
Añadir fetchpriority="high"Medio
Preload con <link>Alto
Formato WebP/AVIFMedio
Tamaño correcto (no escalar)Medio
Servir desde CDNAlto
Reducir peso del archivoAlto

Optimiza el peso de las imágenes

Es común encontrar thumbnails de 1MB cuando deberían pesar 50KB. Las causas típicas:

ProblemaSolución
Imagen original sin comprimirCompresión con calidad 80-85%
Formato inadecuadoWebP o AVIF en lugar de PNG/JPEG
Resolución excesivaServir tamaño real, no escalar en el navegador
Sin CDN con optimizaciónUsar CDN que optimice on-the-fly

Herramientas de compresión:

  • Squoosh (web): Comparación visual de formatos y calidad
  • ImageOptim (Mac): Batch processing local
  • Sharp (Node): Automatización en build
<!-- Servir imagen al tamaño que se muestra -->
<img src="producto-400w.webp"
     srcset="producto-400w.webp 400w,
             producto-800w.webp 800w"
     sizes="(max-width: 600px) 400px, 800px"
     alt="Producto">

Un thumbnail de 400x300px no debería pesar más de 50-80KB en WebP con calidad 80%.

<!-- Preload de imagen LCP -->
<link rel="preload" as="image" href="/hero.webp" fetchpriority="high">

Preconnect: Solo a orígenes críticos

El preconnect le dice al navegador que inicie la conexión (DNS + TCP + TLS) a un dominio antes de que lo necesite. Ahorra el tiempo de handshake cuando finalmente se hace la petición.

Pero hay un coste: cada preconnect consume recursos del navegador. Si preconectas a 10 dominios pero solo 2 son críticos, estás desperdiciando recursos en conexiones que no importan.

Qué preconectar

Solo los orígenes que bloquean el renderizado o afectan al LCP:

Origen¿Preconnect?Razón
CDN de imágenes críticasAfecta LCP directamente
Cookie consent (Cookielaw, OneTrust)Puede ser LCP si muestra banner
Fonts de GoogleBloquea renderizado de texto
AnalyticsNoNo es crítico para el usuario
Tracking pixelsNoCarga diferida está bien
Social embedsNoNormalmente below the fold

Implementación correcta

<head>
  <!-- Preconnect solo a orígenes críticos -->
  <link rel="preconnect" href="https://cdn.tudominio.com">
  <link rel="preconnect" href="https://cdn.tudominio.com" crossorigin>
  <link rel="preconnect" href="https://cdn.cookielaw.org">
  <link rel="preconnect" href="https://cdn.cookielaw.org" crossorigin>

  <!-- NO hagas esto: -->
  <!-- <link rel="dns-prefetch" href="..."> (preconnect ya incluye DNS) -->
  <!-- <link rel="preconnect" href="https://google-analytics.com"> (no crítico) -->
</head>

Nota: necesitas dos líneas por dominio si sirves recursos CORS (fonts, fetch requests) y no-CORS (imágenes): una con crossorigin y otra sin él.

Elimina cualquier dns-prefetch si ya tienes preconnect al mismo dominio; es redundante.

font-display: Evita texto invisible

Las fuentes web pueden causar FOIT (Flash of Invisible Text): el navegador espera a que la fuente cargue antes de mostrar el texto. Durante esa espera, el usuario ve… nada.

El problema

Sin font-display, el comportamiento por defecto varía entre navegadores, pero muchos esperan hasta 3 segundos antes de mostrar una fuente fallback. Eso es inaceptable.

/* MAL: Sin font-display */
@font-face {
  font-family: 'MiFuente';
  src: url('/fonts/mifuente.woff2') format('woff2');
  /* El navegador decide qué hacer */
}

Opciones de font-display

ValorComportamientoMejor para
swapMuestra fallback, cambia cuando cargaFuentes de texto
optionalMuestra fallback, solo cambia si carga muy rápidoRendimiento máximo
blockTexto invisible hasta 3sNunca (mala UX)
fallbackCompromiso entre swap y optionalCasos específicos

Mi recomendación:

  • font-display: swap para fuentes de texto principal
  • font-display: optional si el rendimiento es crítico y puedes tolerar no mostrar la fuente web
/* BIEN: font-display explícito */
@font-face {
  font-family: 'MiFuente';
  src: url('/fonts/mifuente.woff2') format('woff2');
  font-display: swap;
}

Auditar fuentes sin font-display

Busca en tus CSS todas las reglas @font-face y verifica que tengan font-display. Si usas Google Fonts, añade &display=swap a la URL:

<link href="https://fonts.googleapis.com/css2?family=Roboto&display=swap" rel="stylesheet">

El <title> debe estar en el HTML inicial

Si tu <title> se inyecta con JavaScript, el usuario ve una pestaña vacía o con texto genérico hasta que el JS se ejecuta. Esto afecta la percepción de velocidad y puede confundir a los usuarios que tienen múltiples pestañas.

<!-- MAL: Title inyectado por JS -->
<head>
  <script>document.title = 'Mi Página';</script>
</head>

<!-- BIEN: Title en HTML -->
<head>
  <title>Mi Página | Mi Sitio</title>
</head>

En frameworks SPA, asegúrate de que el SSR incluya el <title> correcto para cada ruta. Next.js, Nuxt y similares lo manejan automáticamente si usas sus sistemas de metadata.

Herramientas de diagnóstico

HerramientaUsoTipo de datos
PageSpeed InsightsAuditoría rápidaLab + Field
Chrome DevToolsDebugging detalladoLab
WebPageTestTests avanzados, filmstripLab
Search Console (CWV)Datos reales de usuariosField
CrUX DashboardHistórico de métricasField

Los datos de Field (usuarios reales) son los que Google usa para ranking. Los datos de Lab (herramientas) son útiles para debugging pero pueden diferir de la experiencia real.

Cache Keys: No penalices tráfico de campañas

Un problema común en CDN: cada URL con parámetros UTM diferentes se trata como una cache key distinta. Esto significa que el primer usuario que llega desde una campaña de marketing sufre un cache miss completo y debe esperar la respuesta del origen.

# Estas URLs generan 4 cache keys diferentes:
/producto?utm_source=google
/producto?utm_source=meta
/producto?utm_medium=cpc
/producto?utm_campaign=verano2026

Si tu origen tarda 2+ segundos en responder, estás dando la peor experiencia posible a usuarios nuevos que vienen de publicidad.

Solución: Ignorar parámetros de tracking en cache key

Configura tu CDN para excluir estos parámetros de la cache key:

ParámetroUso¿Incluir en cache key?
utm_*Google AnalyticsNo
gclidGoogle AdsNo
fbclidFacebook AdsNo
msclkidMicrosoft AdsNo
page, sortPaginación/filtros
variant, sizeVariante producto

En Akamai, esto se configura en las reglas de cache key con una allowlist de parámetros. En Cloudflare, usa Cache Rules para ignorar query strings específicos.

Verificación

# Debe dar HIT en ambos casos
curl -sI "https://tudominio.com/producto" | grep cache
curl -sI "https://tudominio.com/producto?utm_source=test" | grep cache

Cache-Control: Controla cómo se cachea el HTML

Un problema común: las respuestas HTML no llevan headers de cache-control. Sin instrucciones explícitas, el navegador aplica heurísticas propias, lo que genera comportamiento impredecible.

Opciones de Cache-Control para HTML

DirectivaComportamientoCuándo usarla
no-storeNo cachear nuncaContenido muy dinámico, datos sensibles
no-cacheCachear pero revalidar siempreHTML que puede cambiar frecuentemente
max-age=86400, must-revalidateCachear 24h, luego revalidarSitios con deploys poco frecuentes

Mi recomendación para la mayoría de sitios estáticos o con deploys semanales:

Cache-Control: max-age=86400, must-revalidate

Esto permite al navegador servir desde cache durante 24 horas, reduciendo latencia en visitas repetidas. must-revalidate asegura que una vez expirado, no se use contenido stale sin verificar con el servidor.

Verificación

En DevTools → Network → selecciona el documento HTML → Headers → busca cache-control. Si está vacío, tienes un problema.

Para más detalle sobre las directivas de cache, Cache-Control for Civilians de Harry Roberts es la mejor referencia.

Orden de recursos en <head>: Third parties y LCP

El orden de los recursos en el <head> importa más de lo que parece. Si inyectas third parties con snippets async después de tu CSS y JS principales bloqueantes, esos snippets no se ejecutan hasta que los recursos principales terminen de cargar.

El problema común

<head>
  <!-- JS y CSS bloqueantes primero -->
  <link rel="stylesheet" href="/styles.css">
  <script src="/main.js"></script>

  <!-- Third parties después - esperan a que termine lo anterior -->
  <script async>
    // Cookie consent, analytics, etc.
  </script>
</head>

El snippet async no empieza a ejecutarse (y por tanto no solicita el script del third party) hasta que styles.css y main.js hayan terminado.

La solución

Mover snippets inline de third parties antes de los recursos bloqueantes:

<head>
  <!-- Third parties primero - se ejecutan antes -->
  <script async>
    // Cookie consent (puede ser LCP si muestra banner)
  </script>

  <!-- Luego JS y CSS bloqueantes -->
  <link rel="stylesheet" href="/styles.css">
  <script src="/main.js"></script>
</head>

Esto es especialmente importante para banners de cookies: si el banner es visible above the fold, puede convertirse en tu LCP candidate. Solicitar el script del banner antes significa que se pinta antes.

No bloquees renderizado por permisos de usuario

Un error grave en páginas que piden geolocalización (como store locators en e-commerce): no renderizar nada hasta que el usuario acepte o rechace el permiso.

El problema

Si condicionas el renderizado a la decisión del usuario:

  1. Usuario llega a la página
  2. Aparece diálogo de “Permitir ubicación”
  3. Usuario piensa, se distrae, o simplemente ignora
  4. La página permanece en blanco
  5. LCP = tiempo hasta que el usuario hace clic

Esto genera scores de LCP potencialmente infinitos. Si el usuario tarda 30 segundos en decidir, tu LCP es 30 segundos.

La solución

Desacoplar renderizado de la decisión del usuario:

// MAL: Bloquear hasta tener permiso
navigator.geolocation.getCurrentPosition(
  (pos) => renderPage(pos),  // Solo renderiza si acepta
  () => renderPage(null)      // Solo renderiza si rechaza
);
// Mientras tanto: pantalla en blanco

// BIEN: Renderizar primero, personalizar después
renderPage();  // Renderiza inmediatamente con estado por defecto
navigator.geolocation.getCurrentPosition(
  (pos) => updateWithLocation(pos),  // Mejora la experiencia si acepta
  () => showManualSearch()            // Ofrece alternativa si rechaza
);

El contenido principal debe ser visible debajo del diálogo de permisos. El usuario puede ver la página mientras decide.

Principio general

Nunca condiciones el first paint a una acción del usuario. Renderiza primero con un estado por defecto razonable, y mejora la experiencia después si el usuario da permisos adicionales.

Elimina redirects innecesarios

Cada redirect añade un round-trip completo antes de que el navegador pueda empezar a descargar el contenido real. En conexiones lentas o con alta latencia, esto puede añadir 200-500ms al LCP.

Redirects comunes que deberías eliminar

Tipo de redirectEjemploSolución
www vs no-wwwejemplo.comwww.ejemplo.comEnlazar directamente a la versión canonical
HTTP → HTTPShttp://https://HSTS preload, enlaces siempre con HTTPS
Trailing slash/pagina/pagina/Consistencia en enlaces internos
Country redirect//es/Servir contenido localizado sin redirect
Mobile redirectwwwm.Responsive design en lugar de sitio móvil
Marketing redirects/campaign/producto?utm=...Enlazar directamente a la URL final

Cómo detectarlos

En DevTools → Network → filtra por el documento HTML → revisa la columna “Status”. Si ves 301 o 302, tienes un redirect.

También puedes usar:

curl -sI "https://tudominio.com" | grep -i location

Si devuelve un header Location, hay redirect.

Cadenas de redirects

El peor escenario es una cadena: http://ejemplo.comhttps://ejemplo.comhttps://www.ejemplo.comhttps://www.ejemplo.com/es/. Cada salto es un round-trip adicional.

Audita tus enlaces internos y asegúrate de que apuntan directamente a la URL final, sin pasar por redirects intermedios.

Priorización de mejoras

Si tienes que elegir por dónde empezar:

  1. Imagen LCP optimizada - Mayor impacto en LCP, fácil de implementar
  2. SSR o placeholders - Resuelve CLS de raíz
  3. Brotli - Mejora todas las métricas, configuración de servidor
  4. HTTP/3 - Mejora latencia, solo requiere activar en CDN
  5. Cache-Control en HTML - Mejora visitas repetidas
  6. Orden de third parties - Mejora LCP cuando hay banners

Las optimizaciones de servidor (Brotli, HTTP/3, Cache-Control) se hacen una vez y benefician a todo el sitio. Las de contenido (imágenes, placeholders) requieren revisión página por página.

Google documenta todas las métricas y técnicas en web.dev. Es la referencia más actualizada.