← Todos los articulos

De 76 a 100: Cómo lograr una puntuación perfecta en Lighthouse

TL;DR: Un sitio de portafolio personal pasó de una puntuación de rendimiento móvil de 76 en Lighthouse 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 sobrescritura particularmente escurridiza de una variable CSS que causaba cambios de diseño en 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 superior a 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, limpié 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">

Ratios de contraste

Los encabezados del pie de página y las etiquetas de formulario usaban --color-text-tertiary (blanco con 40% de opacidad). Los 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 iconos de redes sociales tenían texto alternativo como “LinkedIn icon” cuando estaban dentro de enlaces que ya tenían aria-labels. Los cambié a alt="" con aria-hidden="true" para evitar la repetición del lector de pantalla.

Texto de enlace idéntico

Múltiples enlaces “View Case Study” eran indistinguibles para los lectores de pantalla. Añadí 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 saltó de 91 a 100.


Fase 2: El problema del CSS que bloquea el renderizado

Lighthouse mostró un “retraso en el renderizado del elemento” de 2,460ms en el desglose del LCP. El culpable: un archivo CSS síncrono bloqueando 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 toda la hoja de estilos de 25KB antes de pintar cualquier cosa.

La solución: CSS crítico + carga asíncrona

Paso 1: Extraer el CSS crítico (above-the-fold) en un archivo separado que se incluye en línea 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 bloquea. Una vez cargada, el manejador 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 para los componentes above-the-fold:

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 del renderizado del elemento LCP bajó de 2,460ms a ~300ms.


Fase 3: La pesadilla del CLS comienza

Después de implementar el CSS asíncrono, el CLS en realidad empeoró—saltó a 0.119. Lighthouse mostró <main> como el elemento que se desplazaba.

Bug #1: Comentarios CSS contaminando el regex

El script de extracción usaba un regex para coincidir con selectores:

rule_pattern = r"([.#\w][^{]+)\{([^}]+)\}"

Problema: Los comentarios CSS no se eliminaban antes de la extracción. Un comentario como:

/* Hero Section - Editorial */
.hero { ... }

Causaba que el regex coincidiera desde “Hero Section” hasta la llave de apertura, creando selectores malformados que fallaban la verificación de prefijos. Los estilos críticos de .hero se descartaban silenciosamente.

Solución: Eliminar los comentarios antes de cualquier operación con regex:

css_no_comments = re.sub(r'/\*[\s\S]*?\*/', '', css_content)

Bug #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 sobrescritura móvil se aplicaba incorrectamente.

Solució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 fold 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. El CLS seguía en 0.116. El desplazamiento ocurría dentro del viewport.


Fase 5: La prueba definitiva

El filmstrip de Lighthouse mostró el texto del hero desplazándose visiblemente entre fotogramas. No desvaneciéndose—moviéndose horizontalmente.

Investigación profunda de qué estilos afectan el posicionamiento horizontal: - .hero__content usa padding: 0 var(--gutter) - --gutter está definido 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:

  1. El CSS crítico carga: --gutter: 48px
  2. El hero se renderiza con 48px de padding lateral
  3. El CSS completo carga de forma asíncrona
  4. La media query establece --gutter: 24px
  5. El padding del hero se reduce de 48px a 24px
  6. El texto se reajusta y se desplaza = CLS 0.116

La solució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 principio. Cuando el CSS completo carga, --gutter ya es 24px—sin cambios, sin desplazamiento.


Fase 6: CSP y Alpine.js

Un problema más: Alpine.js lanzaba 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.6s 0.8s

La prueba

Auditoría de escritorio mostrando puntuaciones perfectas en las cuatro categorías:

Lighthouse desktop audit showing 100/100/100/100 scores

Auditoría móvil—la que más importa—también alcanzando puntuaciones perfectas:

Lighthouse mobile audit showing 100/100/100/100 scores


Conclusiones clave

1. Las variables CSS pueden causar CLS

Las sobrescrituras de propiedades personalizadas CSS en media queries son invisibles en el panel de estilos computados de DevTools—solo ves el valor final. Si tu CSS crítico no incluye las sobrescrituras de variables, el diseño se desplazará cuando 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 a 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 estilo sin complejidad de JavaScript.

4. La depuración de CLS requiere el filmstrip

La vista de filmstrip de Lighthouse muestra exactamente cuándo ocurren los desplazamientos. Sin ella, estás adivinando. El elemento resaltado como “desplazándose” podría ser solo un síntoma—busca qué lo está causando.

5. Las secciones hero de 100vh tienen beneficios más allá de la estética

Si tu hero llena el viewport, el contenido debajo del fold no puede contribuir al CLS porque no se mide hasta que el usuario hace scroll.


El stack tecnológico

  • Backend: FastAPI + Jinja2
  • Frontend: HTMX + Alpine.js (auto-alojado, 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 filmstrip de Lighthouse para depuración de CLS
  • grep y regex101.com para 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