← Todos los articulos

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:

  1. Se carga el CSS crítico: --gutter: 48px
  2. El hero se renderiza con 48px de padding lateral
  3. Se carga el CSS completo 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 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 de Lighthouse en escritorio mostrando puntuaciones de 100/100/100/100

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

Auditoría de Lighthouse en móvil mostrando puntuaciones de 100/100/100/100


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
  • 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

Artículos relacionados

Midjourney V8 Killed Your V7 Workflow: What Actually Changed

V8 isn't a better V7. It's a different creative loop: personalization over prompting, native 2K, conversation mode, and …

15 min de lectura

Every Iteration Makes Your Code Less Secure

43.7% of LLM iteration chains introduce more vulnerabilities than baseline. Adding SAST scanners makes it worse. SCAFFOL…

11 min de lectura

Your Agent Sandbox Is a Suggestion

An attacker opened a GitHub issue and shipped malware in Cline's next release. Agent sandboxes fail at three levels. Her…

18 min de lectura