← Todos los artículos

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. Acertar con el contraste forma parte de un sistema de color más amplio: la uniformidad perceptual importa por accesibilidad, no solo por estética.

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. Esto resulta especialmente peligroso en los sistemas tipográficos, donde las variables de tamaño de fuente e interlineado se propagan en cascada por cada elemento de texto.

La ciencia del color que sustenta el sistema de color perceptual del sitio también influyó: entre las sobreescrituras de variables CSS que causaron el CLS había variables de color junto a las de espaciado, y la corrección se aplicó a ambas.

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. Esto encaja con los principios del diseño editorial: un hero a pantalla completa aporta tanto impacto visual como una ventaja de rendimiento.


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

Todo el stack funciona sin herramientas de compilación: nada de webpack, Vite ni bundlers. El Manifiesto No-Build documenta las métricas completas y las concesiones de este enfoque. Esa simplicidad también hizo más fácil auditar el sistema de diseño: hay menos capas entre el CSS que uno escribe y lo que recibe el navegador. Este artículo pertenece al hub de design engineering que reúne, en todo el sitio, el cruce entre oficio e implementación.


Preguntas frecuentes

¿Qué se considera una buena puntuación de Lighthouse?

Google considera buena una puntuación de 90 o superior. Alcanzar 100 en las cuatro categorías (rendimiento, accesibilidad, buenas prácticas y SEO) es poco común en móvil: las condiciones reales — velocidades de red variables, limitación de CPU, scripts de terceros — dificultan controlar cada métrica. La mayoría de los sitios en producción obtiene entre 50 y 80 en rendimiento móvil.

¿Qué causa el Cumulative Layout Shift (CLS)?

El CLS mide cuánto se desplaza el contenido visible después del renderizado inicial. Entre las causas habituales están las imágenes sin dimensiones explícitas, el contenido inyectado dinámicamente, las fuentes web que se intercambian con las de respaldo (FOIT/FOUT) y, como documenta este artículo, las propiedades personalizadas CSS que cambian de valor cuando se carga una hoja de estilos diferida. Google considera “deficiente” un CLS superior a 0,1 y “bueno” uno inferior a 0,1.

¿Cómo mejora el CSS crítico el rendimiento en Lighthouse?

El CSS crítico extrae los estilos necesarios para el contenido visible al cargar y los incrusta en el <head> del HTML. Así se elimina el viaje de ida y vuelta que bloquea el renderizado, en el que el navegador descarga y analiza una hoja de estilos completa antes de pintar nada. El resto del CSS se carga de forma asíncrona con la técnica media="print", de modo que el navegador la trata como no bloqueante. En este caso, el retraso de renderizado del elemento LCP bajó de 2.460 ms a unos 300 ms.

¿Se puede lograr una puntuación perfecta en Lighthouse sin herramientas de compilación?

Sí. Este sitio usa CSS puro, sin preprocesador, sin bundler y sin framework: solo FastAPI sirviendo plantillas Jinja2. Un script de Python se encarga de la extracción de CSS crítico en el momento del despliegue. La ausencia de herramientas de compilación simplifica incluso la auditoría de rendimiento, porque hay menos capas de abstracción entre el CSS de origen y lo que recibe el navegador.

¿Por qué importa más la puntuación móvil de Lighthouse que la de escritorio?

Google usa indexación centrada en móvil, así que la versión móvil de su sitio determina el posicionamiento en las búsquedas. Las auditorías móviles de Lighthouse aplican limitación de CPU (4 veces más lenta) y de red (4G lenta simulada), lo que expone problemas de rendimiento invisibles en escritorio. Un sitio que obtiene 100 en escritorio suele quedar entre 20 y 30 puntos por debajo en móvil bajo esas restricciones.


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

Rendimiento e interoperabilidad de SwiftUI en iOS 27

Cómo SwiftUI en iOS 27 maneja el scroll de lazy stacks, los shaders en GPU y la interoperabilidad con AppKit/UIKit, segú…

26 min de lectura

Lo que dijo el equipo de rendimiento de Apple en el lab de la WWDC26

El equipo de Power & Performance de Apple respondió preguntas en la WWDC26. Guía de campo sobre MetricKit, Power Profile…

19 min de lectura

El stack de agentes del ingeniero de diseño

Los seis componentes de una infraestructura de agentes que garantiza consistencia visual, disciplina tipográfica y cumpl…

16 min de lectura