# De 89 a 100 en Lighthouse: cómo llevé mi web al rendimiento máximo en una mañana

> Optimización real de un sitio Astro estático: eliminé render-blocking, apliqué el patrón Facade al chatbot y corregí contraste WCAG. De 89/96/92 a 100/100/100 en Lighthouse.

**Autor:** Pedro Luis Cuevas Villarrubia | **Fecha:** 2026-05-10 | **Tags:** rendimiento, Lighthouse, Astro
**URL:** https://asturwebs.es/blog/lighthouse-100-optimizar-rendimiento-web-2026/

---
Esta mañana abrí PageSpeed Insights, escribí asturwebs.es y me encontré con un 89 de rendimiento en móvil. No estaba mal, pero no era suficiente. Dos horas después, tenía un **100/100/100** en rendimiento, accesibilidad y SEO.

No fue magia. Fue método. Te cuento qué hice, por qué funcionó, y qué aprendí.

<div class="not-prose my-8 bg-amber-50 border-l-4 border-amber-400 rounded-r-lg p-4">
<p class="text-sm font-semibold text-amber-800 mb-1">Nota técnica</p>
<p class="text-sm text-amber-700">Lighthouse mide en "laboratorio" (simula 4G lento, CPU throttled). Para el ranking real, Google usa datos de campo de usuarios reales (CrUX). Un 100 en Lighthouse no garantiza que todos los usuarios vean lo mismo, pero demuestra que el sitio tiene el potencial óptimo. Y eso impacta positivamente en los datos reales.</p>
</div>

## El punto de partida

Mi web está construida con **Astro 5** (HTML estático), React para componentes interactivos, y una API en Hono. Nada de WordPress, nada de PHP. A pesar de ser un site estático, Lighthouse me daba:

- **Rendimiento: 89** — FCP 2.6s, LCP 3.2s, Speed Index 2.6s
- **Accesibilidad: 96** — Problemas de contraste
- **SEO: 92** — Enlace poco descriptivo

El diagnóstico fue claro: tres cosas bloqueaban el rendimiento.

## Fix 1: Google Fonts te cuesta 750 milisegundos

Cargar la fuente Inter desde los servidores de Google adds tres requests: DNS, TLS y la descarga del CSS. En 4G lento, eso son **750 ms de render-blocking** — el navegador no pinta nada hasta que resuelve la fuente.

**Solución:** Descargué los archivos woff2 de Inter (latin + latin-ext, solo lo que necesita una web en español) y los serví desde mi propio dominio.

Resultado: 750 ms de bloqueo → 0 ms. Y de paso eliminé dependencia de terceros.

<img src="/images/90-movil.webp" alt="Lighthouse 90 móvil — punto de partida" width="1023" height="947" />

## Fix 2: Google Analytics se cargaba DOS veces

Este fue un error mío. Tenía GA en el `<head>` de Layout.astro (para todos los visitantes) Y en el componente CookieConsent (solo si aceptaban cookies). Resultado: 153 KiB de JS innecesario para usuarios que no habían aceptado cookies, y un problema de GDPR.

**Solución:** Eliminé el script de Layout y dejé solo el de CookieConsent, que carga GA únicamente tras el consentimiento.

Ahorro: 153 KiB de JS + 196 ms de hilo principal. Y ahora es legal.

## Fix 3: El ChatWidget mataba el Speed Index

El componente ChatWidget.tsx usa React con `useState`, `useEffect`, `useCallback`... todo eso se hidrataba con `client:load`, lo que significa que el navegador lo descargaba y ejecutaba **antes de pintar la página**.

El chat pesa poco (~5 KiB), pero arrastra React entero (54 KiB). El problema no era el tamaño del archivo — era el **coste de hidratación**: la CPU del móvil pasaba 80ms "desempaquetando" React y evaluando el componente antes de permitir que el usuario hiciera scroll. Esos 80ms de Total Blocking Time (TBT) son tareas largas que congelan el hilo principal del navegador.

**Solución:** Implementé un **patrón Facade**. Creé un `ChatWrapper.astro` que renderiza un botón estático en HTML puro (0 JavaScript). Solo cuando el usuario hace clic, un `import()` dinámico carga React y el chat completo. El JavaScript se ejecuta en un momento de ocio del navegador, no durante el renderizado crítico.

```
<!-- Antes: React se hidrataba siempre, bloqueando el hilo principal -->
<ChatWidget client:load />

<!-- Después: HTML estático, React solo al hacer clic -->
<ChatWrapper />  <!-- botón HTML, 0 JS -->
```

El Speed Index bajó de **4.1s a 2.4s**. El TBT pasó de 80ms a **0ms** — la CPU del móvil ya no tiene que procesar React antes de mostrar la página.

<img src="/images/97-movil.webp" alt="Lighthouse 97 móvil — tras eliminar GA y fonts" width="1024" height="988" />

## Fix 4: Contraste WCAG — de 96 a 100 en accesibilidad

Lighthouse marcaba varios elementos con contraste insuficiente. La lección: **el mismo color puede fallar en un fondo y pasar en otro**. Hay que calcular contra el fondo real.

<div class="not-prose space-y-3 my-8">
  <div class="flex items-start gap-3 bg-white border border-gray-200 rounded-lg p-4">
    <span class="shrink-0 text-xs font-bold px-2 py-1 rounded bg-green-100 text-green-700">AA</span>
    <div>
      <p class="text-sm font-semibold text-gray-900">Fechas blog (sobre blanco)</p>
      <p class="text-sm text-gray-500"><code class="text-xs bg-gray-100 px-1 rounded">text-gray-400</code> (2.95:1) &rarr; <code class="text-xs bg-gray-100 px-1 rounded">text-gray-500</code> (5.74:1)</p>
    </div>
  </div>
  <div class="flex items-start gap-3 bg-white border border-gray-200 rounded-lg p-4">
    <span class="shrink-0 text-xs font-bold px-2 py-1 rounded bg-green-100 text-green-700">AAA</span>
    <div>
      <p class="text-sm font-semibold text-gray-900">Footer (sobre gray-900)</p>
      <p class="text-sm text-gray-500"><code class="text-xs bg-gray-100 px-1 rounded">text-gray-500</code> (3.67:1) &rarr; <code class="text-xs bg-gray-100 px-1 rounded">text-gray-400</code> (~7:1)</p>
    </div>
  </div>
  <div class="flex items-start gap-3 bg-white border border-gray-200 rounded-lg p-4">
    <span class="shrink-0 text-xs font-bold px-2 py-1 rounded bg-green-100 text-green-700">AA</span>
    <div>
      <p class="text-sm font-semibold text-gray-900">Enlace cookies (sobre oscuro)</p>
      <p class="text-sm text-gray-500"><code class="text-xs bg-gray-100 px-1 rounded">text-primary</code> (2.2:1) &rarr; <code class="text-xs bg-gray-100 px-1 rounded">text-indigo-400</code> (5.1:1)</p>
    </div>
  </div>
  <div class="flex items-start gap-3 bg-white border border-gray-200 rounded-lg p-4">
    <span class="shrink-0 text-xs font-bold px-2 py-1 rounded bg-green-100 text-green-700">AAA</span>
    <div>
      <p class="text-sm font-semibold text-gray-900">Badges servicios (sobre primary)</p>
      <p class="text-sm text-gray-500"><code class="text-xs bg-gray-100 px-1 rounded">bg-white/20</code> (4.19:1) &rarr; <code class="text-xs bg-gray-100 px-1 rounded">border-white/40</code> (6.28:1)</p>
    </div>
  </div>
</div>

El caso de los badges es interesante: el fondo `bg-white/20` aclaraba el indigo, reduciendo contraste con el texto blanco. Cambié a un borde semitransparente — el texto se compara contra el fondo original, no contra el badge.

## Fix 5: Nginx no cacheaba .webp

Mi configuración de Nginx cacheaba png, jpg, svg... pero no `.webp`. El logo y todas las imágenes del blog se re-descargaban en cada visita.

**Solución:** Añadir `webp` y `avif` al regex de caché: 30 días, `immutable`.

## El resultado

### Escritorio

<img src="/images/100-escritorio.webp" alt="Lighthouse 100 escritorio" width="1261" height="1690" />

<div class="not-prose my-8 overflow-hidden rounded-xl border border-gray-200">
  <div class="grid grid-cols-4 bg-gray-50 px-4 py-3 border-b border-gray-200">
    <p class="text-xs font-bold text-gray-500 uppercase tracking-wider">Metrica</p>
    <p class="text-xs font-bold text-gray-500 uppercase tracking-wider">Antes</p>
    <p class="text-xs font-bold text-gray-500 uppercase tracking-wider">Despues</p>
    <p class="text-xs font-bold text-gray-500 uppercase tracking-wider">Mejora</p>
  </div>
  <div class="grid grid-cols-4 px-4 py-3 border-b border-gray-100 hover:bg-gray-50">
    <p class="text-sm font-semibold text-gray-900">Rendimiento</p>
    <p class="text-sm text-gray-500">~96</p>
    <p class="text-sm font-bold text-green-600">100</p>
    <p class="text-sm text-green-600">+4</p>
  </div>
  <div class="grid grid-cols-4 px-4 py-3 border-b border-gray-100 hover:bg-gray-50">
    <p class="text-sm font-semibold text-gray-900">Accesibilidad</p>
    <p class="text-sm text-gray-500">96</p>
    <p class="text-sm font-bold text-green-600">100</p>
    <p class="text-sm text-green-600">+4</p>
  </div>
  <div class="grid grid-cols-4 px-4 py-3 border-b border-gray-100 hover:bg-gray-50">
    <p class="text-sm font-semibold text-gray-900">Practicas recomendadas</p>
    <p class="text-sm text-gray-500">~97</p>
    <p class="text-sm font-bold text-green-600">100</p>
    <p class="text-sm text-green-600">+3</p>
  </div>
  <div class="grid grid-cols-4 px-4 py-3 border-b border-gray-100 hover:bg-gray-50">
    <p class="text-sm font-semibold text-gray-900">SEO</p>
    <p class="text-sm text-gray-500">~95</p>
    <p class="text-sm font-bold text-green-600">100</p>
    <p class="text-sm text-green-600">+5</p>
  </div>
  <div class="grid grid-cols-4 px-4 py-3 border-b border-gray-100 hover:bg-gray-50">
    <p class="text-sm font-semibold text-gray-900">FCP</p>
    <p class="text-sm text-gray-500">~0.9s</p>
    <p class="text-sm font-bold text-green-600">0.3s</p>
    <p class="text-sm text-green-600">-67%</p>
  </div>
  <div class="grid grid-cols-4 px-4 py-3 border-b border-gray-100 hover:bg-gray-50">
    <p class="text-sm font-semibold text-gray-900">LCP</p>
    <p class="text-sm text-gray-500">~1.0s</p>
    <p class="text-sm font-bold text-green-600">0.3s</p>
    <p class="text-sm text-green-600">-70%</p>
  </div>
  <div class="grid grid-cols-4 px-4 py-3 border-b border-gray-100 hover:bg-gray-50">
    <p class="text-sm font-semibold text-gray-900">Speed Index</p>
    <p class="text-sm text-gray-500">~0.9s</p>
    <p class="text-sm font-bold text-green-600">0.5s</p>
    <p class="text-sm text-green-600">-44%</p>
  </div>
  <div class="grid grid-cols-4 px-4 py-3 border-b border-gray-100 hover:bg-gray-50">
    <p class="text-sm font-semibold text-gray-900">TBT</p>
    <p class="text-sm text-gray-500">~20ms</p>
    <p class="text-sm font-bold text-green-600">0ms</p>
    <p class="text-sm text-green-600">-100%</p>
  </div>
  <div class="grid grid-cols-4 px-4 py-3 hover:bg-gray-50">
    <p class="text-sm font-semibold text-gray-900">CLS</p>
    <p class="text-sm text-gray-500">0</p>
    <p class="text-sm font-bold text-green-600">0</p>
    <p class="text-sm text-gray-400">&mdash;</p>
  </div>
</div>

<div class="not-prose my-6 bg-gray-50 border-l-4 border-gray-300 rounded-r-lg p-4">
<p class="text-sm text-gray-600">Los valores "Antes" de escritorio son estimaciones basadas en las metricas de movil y las diferencias tipicas entre Lighthouse movil/escritorio.</p>
</div>

### Móvil

<img src="/images/100-movil.webp" alt="Lighthouse 100 móvil — resultado final" width="1248" height="1776" />

<div class="not-prose my-8 overflow-hidden rounded-xl border border-gray-200">
  <div class="grid grid-cols-4 bg-gray-50 px-4 py-3 border-b border-gray-200">
    <p class="text-xs font-bold text-gray-500 uppercase tracking-wider">Metrica</p>
    <p class="text-xs font-bold text-gray-500 uppercase tracking-wider">Antes</p>
    <p class="text-xs font-bold text-gray-500 uppercase tracking-wider">Despues</p>
    <p class="text-xs font-bold text-gray-500 uppercase tracking-wider">Mejora</p>
  </div>
  <div class="grid grid-cols-4 px-4 py-3 border-b border-gray-100 hover:bg-gray-50">
    <p class="text-sm font-semibold text-gray-900">Rendimiento</p>
    <p class="text-sm text-red-500">89</p>
    <p class="text-sm font-bold text-green-600">100</p>
    <p class="text-sm text-green-600">+11</p>
  </div>
  <div class="grid grid-cols-4 px-4 py-3 border-b border-gray-100 hover:bg-gray-50">
    <p class="text-sm font-semibold text-gray-900">Accesibilidad</p>
    <p class="text-sm text-gray-500">96</p>
    <p class="text-sm font-bold text-green-600">100</p>
    <p class="text-sm text-green-600">+4</p>
  </div>
  <div class="grid grid-cols-4 px-4 py-3 border-b border-gray-100 hover:bg-gray-50">
    <p class="text-sm font-semibold text-gray-900">Practicas recomendadas</p>
    <p class="text-sm text-gray-500">~98</p>
    <p class="text-sm font-bold text-green-600">100</p>
    <p class="text-sm text-green-600">+2</p>
  </div>
  <div class="grid grid-cols-4 px-4 py-3 border-b border-gray-100 hover:bg-gray-50">
    <p class="text-sm font-semibold text-gray-900">SEO</p>
    <p class="text-sm text-amber-500">92</p>
    <p class="text-sm font-bold text-green-600">100</p>
    <p class="text-sm text-green-600">+8</p>
  </div>
  <div class="grid grid-cols-4 px-4 py-3 border-b border-gray-100 hover:bg-gray-50">
    <p class="text-sm font-semibold text-gray-900">FCP</p>
    <p class="text-sm text-red-500">2.6s</p>
    <p class="text-sm font-bold text-green-600">1.2s</p>
    <p class="text-sm text-green-600">-54%</p>
  </div>
  <div class="grid grid-cols-4 px-4 py-3 border-b border-gray-100 hover:bg-gray-50">
    <p class="text-sm font-semibold text-gray-900">LCP</p>
    <p class="text-sm text-red-500">3.2s</p>
    <p class="text-sm font-bold text-green-600">1.4s</p>
    <p class="text-sm text-green-600">-56%</p>
  </div>
  <div class="grid grid-cols-4 px-4 py-3 border-b border-gray-100 hover:bg-gray-50">
    <p class="text-sm font-semibold text-gray-900">Speed Index</p>
    <p class="text-sm text-red-500">2.6s</p>
    <p class="text-sm font-bold text-green-600">1.2s</p>
    <p class="text-sm text-green-600">-54%</p>
  </div>
  <div class="grid grid-cols-4 px-4 py-3 border-b border-gray-100 hover:bg-gray-50">
    <p class="text-sm font-semibold text-gray-900">TBT</p>
    <p class="text-sm text-red-500">80ms</p>
    <p class="text-sm font-bold text-green-600">0ms</p>
    <p class="text-sm text-green-600">-100%</p>
  </div>
  <div class="grid grid-cols-4 px-4 py-3 hover:bg-gray-50">
    <p class="text-sm font-semibold text-gray-900">CLS</p>
    <p class="text-sm text-gray-500">0</p>
    <p class="text-sm font-bold text-green-600">0.002</p>
    <p class="text-sm text-gray-400">&mdash;</p>
  </div>
</div>

<img src="/images/2026-05-asturwebs-central-lighthouse-rendimiento.webp" alt="Optimización Lighthouse — métricas de rendimiento web" width="1280" height="768" />

## Sobre la variabilidad de las puntuaciones

Antes de que alguien se obsesione con mantener el 100 exacto, conviene una aclaración: **una puntuación de Lighthouse no es un examen con nota fija**. Es una estimación en condiciones de laboratorio controladas (CPU throttled, red 4G simulada), y tiene una variabilidad inherente de entre 2 y 5 puntos entre ejecuciones consecutivas. Google lo documenta abiertamente en su guía de [Lighthouse Variability](https://developer.chrome.com/docs/lighthouse/performance/performance-scoring#how_is_the_score_calculated).

La curva de puntuación es logarítmica: subir de 50 a 70 es relativamente sencillo; subir de 95 a 100 requiere ajustes marginales que en muchos casos no tienen impacto perceptible para el usuario. La diferencia real entre un 92 y un 100 en rendimiento no la nota nadie navegando tu web — la nota Lighthouse, que simula un Moto G4 con CPU reducida al 4x de lentitud.

Lo que sí importa de verdad es el color: **estar en verde (90+) significa que tu web está en el cuartil superior** de la distribución de rendimiento que Google considera aceptable. Y para el posicionamiento real, Google utiliza datos de campo (CrUX, medidos con usuarios reales), no las puntuaciones de laboratorio. Un 90 consistente en campo es más valioso que un 100 en laboratorio que no se sostiene.

Mi objetivo con la optimización fue eliminar problemas estructurales (render-blocking, JS innecesario, contraste insuficiente), no perseguir un número. El 100 es la consecuencia de haber resuelto las causas, no la meta.

## Lo que aprendí

**En un sitio estático, el rendimiento no se mejora con caché de servidor.** Se mejora eliminando lo que bloquea el hilo principal del navegador. JavaScript innecesario, fuentes externas, hidratación prematura.

Cada byte de JS que quitas del renderizado inicial es un paso hacia el 100.

Y si tienes un componente interactivo que el 90% de los usuarios no va a usar en los primeros segundos, **no lo cargues**. Ponle una fachada estática y cárgalo cuando te lo pida.

## Cómo evito que vuelva a bajar a 89

El rendimiento no es un destino, es un estado. Mi CI/CD en GitHub Actions builda y despliega automáticamente en cada push a `main`. Si un nuevo componente o un post pesado degrada las métricas, lo detecto en el siguiente deploy. La clave es que los patrones que he aplicado (self-hosting, facade, sin JS inicial) son estructurales — no dependen de un plugin que se actualiza y rompe todo.

---

<div class="not-prose my-12 bg-indigo-50 border border-indigo-100 rounded-2xl p-8 text-center">
<p class="text-xl font-semibold text-gray-900 mb-2">¿Tu web está en la zona roja o naranja?</p>
<p class="text-gray-600 mb-6">Cada segundo de carga te cuesta conversiones. Auditoría Lighthouse + plan de acción concreto.</p>
<a href="/contacto/" class="inline-block bg-primary text-white px-8 py-3 rounded-lg font-semibold hover:bg-primary-hover transition-colors">Audita mi web →</a>
</div>