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étrica | Bueno | Necesita mejora | Pobre |
|---|---|---|---|
| LCP | ≤2.5s | 2.5s - 4s | >4s |
| INP | ≤200ms | 200ms - 500ms | >500ms |
| CLS | ≤0.1 | 0.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).
| Algoritmo | Ratio compresión | Velocidad | Soporte |
|---|---|---|---|
| Gzip | ~70% | Rápido | Universal |
| Brotli | ~80-85% | Más lento en compresión | 97%+ navegadores |
Cómo verificar qué compresión usas
Abre DevTools → Network → selecciona un archivo de texto → Headers → busca content-encoding:
gzip= Gzipbr= 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.
| Protocolo | Handshake | Multiplexing | Head-of-line blocking |
|---|---|---|---|
| HTTP/1.1 | TCP + TLS separados | No | Sí |
| HTTP/2 | TCP + TLS | Sí | A nivel TCP |
| HTTP/3 | QUIC (integrado) | Sí | 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
| Causa | Impacto CLS | Solución |
|---|---|---|
| Imágenes sin dimensiones | Alto | Añadir width y height |
| Anuncios dinámicos | Muy alto | Reservar espacio fijo |
| Fuentes web (FOUT) | Medio | font-display: optional |
| Contenido inyectado por JS | Alto | SSR o placeholders |
| Infinite scroll | Muy alto | Paginación o SSR |
Server-Side Rendering vs Client-Side
Si tu contenido principal se renderiza con JavaScript en el cliente, tienes dos problemas:
- LCP se retrasa porque el navegador debe descargar JS, parsearlo, ejecutarlo y luego renderizar
- 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:
- Ocupar exactamente el mismo espacio que el contenido final
- Ser responsive para funcionar en todos los viewports
- 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
- DevTools → Performance → grabar carga de página
- Buscar el marcador “LCP” en el timeline
- 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écnica | Impacto |
|---|---|
Quitar loading="lazy" | Alto |
Añadir fetchpriority="high" | Medio |
Preload con <link> | Alto |
| Formato WebP/AVIF | Medio |
| Tamaño correcto (no escalar) | Medio |
| Servir desde CDN | Alto |
| Reducir peso del archivo | Alto |
Optimiza el peso de las imágenes
Es común encontrar thumbnails de 1MB cuando deberían pesar 50KB. Las causas típicas:
| Problema | Solución |
|---|---|
| Imagen original sin comprimir | Compresión con calidad 80-85% |
| Formato inadecuado | WebP o AVIF en lugar de PNG/JPEG |
| Resolución excesiva | Servir tamaño real, no escalar en el navegador |
| Sin CDN con optimización | Usar 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íticas | Sí | Afecta LCP directamente |
| Cookie consent (Cookielaw, OneTrust) | Sí | Puede ser LCP si muestra banner |
| Fonts de Google | Sí | Bloquea renderizado de texto |
| Analytics | No | No es crítico para el usuario |
| Tracking pixels | No | Carga diferida está bien |
| Social embeds | No | Normalmente 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
| Valor | Comportamiento | Mejor para |
|---|---|---|
swap | Muestra fallback, cambia cuando carga | Fuentes de texto |
optional | Muestra fallback, solo cambia si carga muy rápido | Rendimiento máximo |
block | Texto invisible hasta 3s | Nunca (mala UX) |
fallback | Compromiso entre swap y optional | Casos específicos |
Mi recomendación:
font-display: swappara fuentes de texto principalfont-display: optionalsi 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
| Herramienta | Uso | Tipo de datos |
|---|---|---|
| PageSpeed Insights | Auditoría rápida | Lab + Field |
| Chrome DevTools | Debugging detallado | Lab |
| WebPageTest | Tests avanzados, filmstrip | Lab |
| Search Console (CWV) | Datos reales de usuarios | Field |
| CrUX Dashboard | Histórico de métricas | Field |
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ámetro | Uso | ¿Incluir en cache key? |
|---|---|---|
utm_* | Google Analytics | No |
gclid | Google Ads | No |
fbclid | Facebook Ads | No |
msclkid | Microsoft Ads | No |
page, sort | Paginación/filtros | Sí |
variant, size | Variante producto | Sí |
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
| Directiva | Comportamiento | Cuándo usarla |
|---|---|---|
no-store | No cachear nunca | Contenido muy dinámico, datos sensibles |
no-cache | Cachear pero revalidar siempre | HTML que puede cambiar frecuentemente |
max-age=86400, must-revalidate | Cachear 24h, luego revalidar | Sitios 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:
- Usuario llega a la página
- Aparece diálogo de “Permitir ubicación”
- Usuario piensa, se distrae, o simplemente ignora
- La página permanece en blanco
- 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 redirect | Ejemplo | Solución |
|---|---|---|
| www vs no-www | ejemplo.com → www.ejemplo.com | Enlazar directamente a la versión canonical |
| HTTP → HTTPS | http:// → https:// | HSTS preload, enlaces siempre con HTTPS |
| Trailing slash | /pagina → /pagina/ | Consistencia en enlaces internos |
| Country redirect | / → /es/ | Servir contenido localizado sin redirect |
| Mobile redirect | www → m. | 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.com → https://ejemplo.com → https://www.ejemplo.com → https://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:
- Imagen LCP optimizada - Mayor impacto en LCP, fácil de implementar
- SSR o placeholders - Resuelve CLS de raíz
- Brotli - Mejora todas las métricas, configuración de servidor
- HTTP/3 - Mejora latencia, solo requiere activar en CDN
- Cache-Control en HTML - Mejora visitas repetidas
- 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.