¿Cómo construí joaquinmayer.com con Lighthouse 100 y LCP de ~500 ms?
Este sitio carga con Lighthouse 100/100/100/100, LCP de ~500 ms, CLS 0.000 y TBT 0 ms — medido en producción, no en mi laptop. Lo logré en Next.js 15 (App Router) con un puñado de decisiones concretas: fuentes con métricas calibradas (CLS 0), preload quirúrgico solo de la fuente del título, un presupuesto de performance que bloquea cada PR, y RUM real que podés ver en vivo en /perf. Este post es el case study del propio sitio: qué hice, qué no funcionó, y cómo verificarlo vos mismo.
Para vender trabajo técnico, el sitio mismo es la demo. Mostrar el Lighthouse público, el budget en CI y los Core Web Vitals reales es el equivalente a un cirujano mostrando sus suturas.
Los números (medidos, no de marketing)
Lighthouse desktop sobre el build de producción, promedio de varias corridas por página:
| Métrica | Valor | Umbral "good" |
|---|---|---|
| Performance | 100 | — |
| Accessibility / SEO / Best Practices | 100 / 100 / 100 | — |
| LCP (Largest Contentful Paint) | 532–544 ms | < 2500 ms |
| CLS (Cumulative Layout Shift) | 0.000 | < 0.1 |
| TBT (Total Blocking Time) | 0 ms | < 200 ms |
| First Load JS (compartido) | 102 KB | — |
| JS por página | 0.5–6 KB | — |
Esos son números de laboratorio. Los de usuarios reales —que son los que importan— están públicos y en vivo en /perf.
La regla: el sitio es el case study
No optimicé el sitio después de construirlo. Elegí el enfoque al revés: cada decisión de arquitectura tenía que poder defenderse con un número. Abajo, las que movieron la aguja.
1. Fuentes sin layout shift (CLS 0), sin trucos
El error clásico es usar font-display: swap y nada más: la fuente del sistema y la custom tienen métricas distintas (x-height, ancho de avance), y al hacer el swap se ve un salto de layout que el navegador te cobra como CLS.
next/font genera automáticamente un @font-face de fallback con size-adjust + ascent/descent/line-gap-override calibrados. Pero descubrí algo midiendo: para la fuente monospace, ese fallback automático (un Arial estirado al 134%) sobreestima el ancho y hacía que las líneas mono wrappearan de más durante la carga y se desarmaran al swap.
Lo medí forzando el swap visible (retrasando las fuentes) y comparando el alto del documento antes/después:
| Config | Salto de layout en el swap |
|---|---|
| Fallback auto en mono (Arial 134%) | −53 px |
Fallback monospace real (ui-monospace) | 0 px |
La solución fue híbrida: adjustFontFallback encendido para la sans (Inter — Arial calibrado matchea bien), y apagado para la mono, usando una monospace del sistema que ya matchea el ancho sin estirar nada.
// Inter (texto y títulos): el fallback calibrado de Arial matchea bien una sans
Inter({ subsets: ['latin'], display: 'swap', adjustFontFallback: true })
// Monospace: una mono del sistema matchea mejor que un Arial estirado
JetBrains_Mono({
subsets: ['latin'], display: 'swap',
adjustFontFallback: false,
fallback: ['ui-monospace', 'SFMono-Regular', 'Menlo', 'monospace'],
})
Resultado: CLS 0.000, incluso forzando que el swap ocurra a la vista.
2. LCP: el elemento es texto, no una imagen
La mitad de las guías de LCP hablan de optimizar la imagen del hero. Pero medí cuál era mi elemento LCP y resultó ser el <h1> (el hero es text-only). Eso cambia todo:
- Preload solo de la fuente del título (Inter). Preloadear también la monospace competía por ancho de banda con la única fuente del camino crítico.
- El avatar (44×44, decorativo) va con
loading="eager"para que no sea lazy, pero sinfetchpriority="high"— darle prioridad alta a una imagen que no es el LCP le roba ancho de banda al que sí importa.
El LCP quedó dominado por render delay (~440 ms), no por descarga. Para texto, eso significa que el camino crítico es la fuente + el CSS, no una imagen.
3. Presupuesto de JS: 100 KB es el framework, no waste
Acá soy honesto sobre un límite real: los ~102 KB de "First Load JS" son React + el runtime de Next. En App Router eso es irreducible sin migrar a otro framework. Lo que sí controlo es todo lo demás:
- Cero librerías pesadas. Sin Framer Motion, sin GSAP. Las animaciones son CSS scroll-driven.
- Sin analytics de terceros. El RUM es self-hosted (~2 KB, carga async).
- JS por página: 0.5–6 KB. Las páginas son Server Components; solo hay un puñado de islas client chicas.
Un founder técnico sabe que ~100 KB gzip es el framework. Lo que lee como craft es que no haya una sola librería de más.
4. El presupuesto vive en CI: el PR no mergea si se rompe
La performance se degrada en silencio: alguien agrega una dependencia, sube una imagen sin optimizar, mete un script de terceros. Sin enforcement automatizado, te enterás meses después.
Cada PR corre Lighthouse CI con asserts duros — si se rompen, el check queda en rojo y no se puede mergear:
{
"largest-contentful-paint": ["error", { "maxNumericValue": 1000 }],
"total-blocking-time": ["error", { "maxNumericValue": 100 }],
"cumulative-layout-shift": ["error", { "maxNumericValue": 0.05 }],
"categories:performance": ["error", { "minScore": 0.95 }]
}
Además, un bot postea la tabla de métricas en cada PR. El budget no depende de mi disciplina: depende del pipeline.
5. RUM real: lo que mide tu Lighthouse miente con buena fe
Lighthouse corre en una máquina rápida con red ideal. Tu usuario en un Android lento con WiFi de hotel puede tener un INP de 800 ms mientras vos ves 16 ms. Sin Real User Monitoring no lo sabés.
Mido los Core Web Vitals de usuarios reales con web-vitals/attribution — que captura no solo el número sino el elemento DOM específico que causó el problema (el target del INP, el elemento del LCP). Eso es lo accionable: te dice qué arreglar.
web-vitals/attribution → navigator.sendBeacon → /api/vitals → storage propio
Sin cookies, sin terceros, ~2 KB que cargan después de la hidratación. Y está público: podés ver el p75 de usuarios reales —y medir tu propia sesión en vivo— en /perf.
6. Transiciones entre páginas, sin costo de LCP
Las View Transitions cross-document (las de @view-transition en CSS) son lindas y zero-JS, pero tienen un costo documentado: ~70 ms al LCP en repeat views mobile. Acá hay un detalle de arquitectura que la mayoría se saltea: en Next App Router la navegación es client-side (soft-nav), y las transiciones cross-document solo se disparan en navegación de documento real. No aplican.
La versión que sí funciona es same-document (la navegación del router envuelta en startViewTransition). Y tiene un bonus: como la soft-nav no re-mide LCP, no paga los ~70 ms. La transición suave sin el costo.
Lo que NO funcionó (la parte honesta)
Documentar solo los éxitos es marketing. Esto es lo que probé y descarté — con la razón:
- Speculation Rules (prerender). Las implementé y las saqué: verificado con CDP, el prerender no se activa con la soft-nav de Next (el click usa el router client-side, no una navegación de documento). Gastaba CPU prerenderizando páginas que nunca se usaban. El prefetch nativo de
<Link>ya hace el trabajo. - View Transitions cross-document. Cero disparos en navegación Next (mismo motivo). Quedaba como CSS muerto.
- El flag
experimental.viewTransitionde Next. No es viable con React estable — la APIunstable_ViewTransitionsolo existe en el canal experimental de React, y no voy a poner un React pre-release en el sitio que es mi demo. - El endpoint de RUM en edge runtime. Crasheaba con
EvalErroren cada beacon: la emulación edge prohíbe code-generation. Lo pasé a runtime Node. Un endpoint de logging no necesita edge.
Esa lista vale más que la de éxitos: muestra que las decisiones se tomaron midiendo, no copiando un blogpost.
Cómo verificarlo vos mismo
No tenés que creerme:
- /perf — los Core Web Vitals reales de usuarios (p75, últimos 28 días) y la medición en vivo de tu propia sesión, con el elemento DOM del INP.
- Lighthouse CI — el check de performance corre público en cada PR.
- Corré Lighthouse vos mismo sobre cualquier página y compará con la tabla de arriba.
¿Estás construyendo algo donde la performance es palanca de conversión y querés que alguien la trate como un presupuesto, no como un "después lo optimizamos"? Escribime por LinkedIn.