De 76 a 100: Cómo lograr una puntuación perfecta en Lighthouse
Resumen: Un sitio de portafolio personal pasó de una puntuación de rendimiento móvil en Lighthouse de 76 con un CLS de 0,493 a un perfecto 100/100/100/100 en todas las categorías. El proceso reveló problemas sutiles de carga de CSS, errores de regex y una variable CSS particularmente engañosa que causaba desplazamientos de diseño en dispositivos móviles.
El punto de partida
El sitio era un portafolio con FastAPI + Jinja2, usando HTMX y Alpine.js para la interactividad. Auditoría inicial de Lighthouse en móvil:
| Métrica | Puntuación |
|---|---|
| Rendimiento | 76 |
| Accesibilidad | 91 |
| Mejores prácticas | 100 |
| SEO | 100 |
| CLS | 0,493 |
Ese número de CLS es brutal. Google considera “pobre” cualquier valor por encima de 0,1. Con 0,493, la página saltaba visiblemente durante la carga.
Fase 1: Mejoras rápidas de accesibilidad
Antes de abordar el rendimiento, corregí los problemas de accesibilidad para establecer una línea base:
Etiquetas de formulario
Cambié los spans decorativos por elementos <label> apropiados:
<!-- Before: span with aria-describedby -->
<span class="contact-form__label">Email</span>
<input id="email" aria-describedby="...">
<!-- After: proper label association -->
<label for="email" class="contact-form__label">Email</label>
<input id="email">
Relaciones de contraste
Los encabezados del pie de página y las etiquetas de formulario usaban --color-text-tertiary (blanco con 40% de opacidad). Se aumentó a --color-text-secondary (65% de opacidad) para cumplir con los requisitos de contraste WCAG AA de 4,5:1.
Texto alternativo redundante
Los íconos de redes sociales tenían texto alternativo como “LinkedIn icon” cuando estaban dentro de enlaces que ya tenían aria-labels. Se cambió a alt="" con aria-hidden="true" para evitar la repetición en lectores de pantalla.
Texto de enlace idéntico
Múltiples enlaces “View Case Study” eran indistinguibles para los lectores de pantalla. Se agregaron atributos aria-label con los nombres de los proyectos:
<a href="/work/introl" aria-label="View Introl Branding case study">
View Case Study
</a>
Resultado: La accesibilidad subió de 91 a 100.
Fase 2: El problema del CSS que bloquea el renderizado
Lighthouse mostraba un “Element render delay” de 2.460 ms en el desglose del LCP. El culpable: un archivo CSS síncrono que bloqueaba la primera pintura.
<!-- The problem: render-blocking stylesheet -->
<link rel="stylesheet" href="/static/css/styles.min.css">
El navegador tenía que descargar y analizar la hoja de estilos completa de 25 KB antes de pintar cualquier cosa.
La solución: CSS crítico + carga asíncrona
Paso 1: Extraer el CSS crítico (visible sin desplazamiento) en un archivo separado que se incrusta en <head>.
Paso 2: Cargar la hoja de estilos completa de forma asíncrona usando el truco de media="print":
<!-- Critical CSS inlined for instant first paint -->
<style>{% include "components/_critical.css" %}</style>
<!-- Full CSS loaded async (doesn't block render) -->
<link rel="stylesheet" href="/static/css/styles.min.css"
media="print" onload="this.media='all'">
<noscript>
<link rel="stylesheet" href="/static/css/styles.min.css">
</noscript>
El atributo media="print" le dice al navegador “esta hoja de estilos no es necesaria para el renderizado en pantalla”, así que no lo bloquea. Una vez cargada, el handler onload la cambia a media="all".
El script de extracción de CSS crítico
Escribí un script en Python para extraer automáticamente el CSS crítico de los componentes visibles sin desplazamiento:
CRITICAL_PREFIXES = [
".container",
".header",
".nav",
".hero",
".personal-photos",
]
def extract_critical_css(css_content: str) -> str:
# Strip comments to prevent regex pollution
css_no_comments = re.sub(r'/\*[\s\S]*?\*/', '', css_content)
# Extract :root variables
# Extract base reset rules
# Extract component rules by prefix
# Extract media queries containing critical selectors
...
Resultado: El retraso de renderizado del elemento LCP bajó de 2.460 ms a ~300 ms.
Fase 3: Comienza la pesadilla del CLS
Después de implementar el CSS asíncrono, el CLS en realidad empeoró: subió a 0,119. Lighthouse señalaba <main> como el elemento que se desplazaba.
Error #1: Comentarios CSS contaminando el regex
El script de extracción usaba un regex para hacer coincidir selectores:
rule_pattern = r"([.#\w][^{]+)\{([^}]+)\}"
Problema: los comentarios CSS no se eliminaban antes de la extracción. Un comentario como:
/* Hero Section - Editorial */
.hero { ... }
Hacía que el regex coincidiera desde “Hero Section” hasta la llave de apertura, creando selectores malformados que no pasaban la verificación de prefijos. Los estilos críticos de .hero se descartaban silenciosamente.
Corrección: Eliminar los comentarios antes de cualquier operación con regex:
css_no_comments = re.sub(r'/\*[\s\S]*?\*/', '', css_content)
Error #2: Reglas de media queries extraídas como independientes
El regex coincidía con reglas dentro de media queries y las extraía como reglas independientes:
/* Full CSS structure */
.hero__title { font-size: 5rem; } /* Desktop */
@media (max-width: 768px) {
.hero__title { font-size: 1.875rem; } /* Mobile */
}
El script extraía ambas reglas al nivel superior:
/* Broken critical CSS */
.hero__title { font-size: 5rem; }
.hero__title { font-size: 1.875rem; } /* Overrides desktop! */
En móvil, el título se renderizaba con el tamaño móvil desde el CSS crítico, y luego… se quedaba así porque el CSS completo tenía las mismas reglas. Pero en escritorio, la sobreescritura móvil se aplicaba incorrectamente.
Corrección: Rastrear las posiciones de las media queries y omitir las reglas dentro de ellas:
# Find all media query ranges
media_ranges = []
for match in re.finditer(media_pattern, css_no_comments):
media_ranges.append((match.start(), match.end()))
def is_inside_media_query(pos: int) -> bool:
return any(start <= pos < end for start, end in media_ranges)
# Skip rules inside media queries during extraction
for match in re.finditer(rule_pattern, css_no_comments):
if is_inside_media_query(match.start()):
continue # Will be included with full media query block
# ... extract rule
Fase 4: La hipótesis del 100vh
El CLS seguía en 0,116. Teoría: si nada debajo del pliegue es visible en la pintura inicial, no puede contribuir al CLS.
Cambié el hero de 85vh a 100vh:
.hero {
/* 100vh ensures nothing below fold is visible on initial paint */
min-height: 100vh;
}
Resultado: Sin cambios. CLS seguía en 0,116. El desplazamiento ocurría dentro del viewport.
Fase 5: La prueba definitiva
La tira de fotogramas de Lighthouse mostraba el texto del hero desplazándose visiblemente entre cuadros. No se desvanecía, se movía horizontalmente.
Análisis profundo de qué estilos afectan el posicionamiento horizontal:
- .hero__content usa padding: 0 var(--gutter)
- --gutter se define como 48px en :root
Entonces lo encontré:
/* In full CSS, but NOT in critical CSS */
@media (max-width: 768px) {
:root {
--gutter: var(--spacing-md); /* 24px */
}
}
La secuencia en móvil:
- Se carga el CSS crítico:
--gutter: 48px - El hero se renderiza con 48px de padding lateral
- Se carga el CSS completo de forma asíncrona
- La media query establece
--gutter: 24px - El padding del hero se reduce de 48px a 24px
- El texto se redistribuye y se desplaza = CLS 0,116
La corrección
Extraer las media queries de :root como parte del CSS crítico:
# Extract :root media queries (variable overrides are critical)
root_media_pattern = r"@media[^{]+\{\s*:root\s*\{[^}]+\}\s*\}"
for match in re.finditer(root_media_pattern, css_no_comments):
critical_rules.append(match.group())
Ahora el CSS crítico incluye:
:root { --gutter: 48px; /* ... */ }
@media (max-width: 768px) {
:root { --gutter: var(--spacing-md); }
}
El móvil se renderiza con el gutter correcto de 24px desde el inicio. Cuando se carga el CSS completo, --gutter ya es 24px: sin cambios, sin desplazamiento.
Fase 6: CSP y Alpine.js
Un problema más: Alpine.js generaba errores de consola sobre violaciones de Content Security Policy. Alpine usa evaluación dinámica de expresiones internamente.
# In security headers middleware
CSP_DIRECTIVES = {
"script-src": "'self' 'unsafe-inline' 'unsafe-eval'",
# ...
}
La directiva 'unsafe-eval' es necesaria para el análisis de expresiones de Alpine.js. La alternativa es usar la versión compatible con CSP de Alpine, que tiene algunas limitaciones.
Resultados finales
| Métrica | Antes | Después |
|---|---|---|
| Rendimiento | 76 | 100 |
| Accesibilidad | 91 | 100 |
| Mejores prácticas | 100 | 100 |
| SEO | 100 | 100 |
| CLS | 0,493 | 0,033 |
| LCP | 2,6 s | 0,8 s |
La prueba
Auditoría de escritorio mostrando puntuaciones perfectas en las cuatro categorías:

Auditoría en móvil, la que más importa, también alcanzando marcas perfectas:

Conclusiones clave
1. Las variables CSS pueden causar CLS
Las sobreescrituras de propiedades personalizadas CSS en media queries son invisibles en el panel de estilos calculados de DevTools: solo se ve el valor final. Si su CSS crítico no incluye las sobreescrituras de variables, el diseño se desplazará cuando se cargue la hoja de estilos completa.
2. La extracción de CSS crítico es complicada
Los enfoques simples con regex fallan con:
- Comentarios CSS (pueden contaminar la coincidencia de selectores)
- Reglas dentro de media queries (deben permanecer dentro de sus bloques)
- Media queries de :root (los cambios de variables afectan todo)
3. El truco de media="print" funciona
Cargar CSS no crítico con media="print" onload="this.media='all'" es una forma legítima de diferir la carga de hojas de estilos sin complejidad adicional de JavaScript.
4. La depuración de CLS requiere la tira de fotogramas
La vista de tira de fotogramas de Lighthouse muestra exactamente cuándo ocurren los desplazamientos. Sin ella, se trabaja a ciegas. El elemento señalado como “desplazado” podría ser solo un síntoma: busque qué lo causa.
5. Las secciones hero de 100vh tienen beneficios más allá de la estética
Si el hero ocupa todo el viewport, el contenido debajo del pliegue no puede contribuir al CLS porque no se mide hasta que el usuario hace scroll.
El stack
- Backend: FastAPI + Jinja2
- Frontend: HTMX + Alpine.js (autoalojado, sin CDN)
- CSS: CSS puro con propiedades personalizadas, sin preprocesadores
- Optimización: Script personalizado en Python para extracción de CSS crítico
- Hosting: Railway con compresión GZip y caché inmutable
Herramientas utilizadas
- Lighthouse (Chrome DevTools)
- Vista de tira de fotogramas de Lighthouse para depuración de CLS
grepyregex101.compara análisis de CSS- Python para el script de extracción de CSS crítico
- iTerm 2
- Claude Code CLI con Opus 4.5 y mucho ultrathink