enes
Todas las notas

PERFORMANCE

Probé Astro para rehacer este sitio y me quedé con Next.js

Astro ganaba en la métrica que yo creía que importaba. Terminé quedándome con Next porque el sitio dejó de ser contenido estático el día que la home empezó a medir servicios en vivo.

6 min de lectura

Cuando me senté a rehacer este sitio tenía una hipótesis bastante firme: era una web de contenido, cuatro rutas contadas, casi nada de interactividad. El caso de manual para Astro. Islas, cero JavaScript por defecto, HTML plano. Next.js parecía sobredimensionado para lo que estaba haciendo.

Levanté las dos versiones y las medí. Astro ganó. Después me quedé con Next igual. Esta es la parte interesante.

Lo que medí

Armé la misma home en las dos: mismo markup, mismo CSS, mismas fuentes, mismas imágenes. Build de producción en las dos, servidas localmente, Lighthouse 12 con preset desktop.

La diferencia real estaba en el JavaScript. Astro mandaba prácticamente nada. Next mandaba el runtime de React más el payload RSC — decenas de kilobytes que en Astro sencillamente no existían.

Pero las métricas que Lighthouse puntúa terminaron empatadas: las dos versiones daban 100 en performance, TBT en 0 ms, CLS en 0, LCP por debajo del segundo.

Ese empate es la primera cosa que aprendí, y me costó aceptarla: por debajo de cierto umbral, menos JavaScript deja de mejorar los números. Si el HTML crítico llega en el primer viaje y no hay nada bloqueando el hilo principal, recortar 40 KB de JS que se parsean después del first paint no mueve LCP ni TBT. Astro estaba ganando en una métrica que en este sitio ya no era el cuello de botella.

El cuello de botella no era el framework

Mientras comparaba frameworks tenía un problema de performance real, y no lo estaba causando ninguno de los dos.

El fondo del hero eran cuatro gradientes difuminados animados. Algo así:

@keyframes blob1 {
  0%, 100% { transform: scale(1) translate(0, 0); opacity: 0.6; }
  33%      { transform: scale(1.2) translate(30px, -20px); opacity: 0.8; }
}

Cuatro capas con eso, cada una con filter: blur(60px) o más.

Yo había asumido que el problema era el blur — que difuminar es caro y que un radio grande es más caro. Está mal. Un gradiente difuminado se rasteriza una vez y después el compositor reusa ese bitmap; no cuesta nada por frame.

Lo que cuesta es invalidar el bitmap. scale y opacity dentro del keyframe hacen exactamente eso: cada frame el navegador tiene que rehacer el blur, en el hilo principal, cuatro veces.

La corrección fue no tocar el blur en absoluto:

@keyframes drift1 {
  0%, 100% { transform: translate3d(0, 0, 0); }
  50%      { transform: translate3d(6%, -4%, 0); }
}
 
.animate-drift1 { will-change: transform; animation: drift1 44s ease-in-out infinite; }

Solo translate3d. Sin scale, sin opacity. El bitmap difuminado no se invalida nunca y el compositor lo mueve en la GPU. De paso bajé de cuatro capas a dos y las hice mucho más lentas.

Los blurs de hoy son más grandes que los de antes — 100px y 120px contra 60px. La página va mejor.

La regla que me llevé: importa qué se anima, no cuán caro parece el efecto. Y ese problema estaba idéntico en la build de Astro. Ningún framework me lo iba a resolver.

Lo que rompió el empate

Rehice el hero mientras tanto. La versión vieja era la que tiene todo portfolio: título en dos líneas con la segunda en color, dos botones, tres estadísticas, una tarjetita de código decorativa con los tres puntitos de macOS.

La reemplacé por una tabla de estado. Los cinco productos que corro, con su código HTTP real, su TTFB real y su región de edge real, medidos cuando se genera la página. Si uno está caído, la página lo muestra caído.

Ahí el sitio dejó de ser contenido estático.

Necesitaba hacer requests salientes al generar, cachearlos, revalidarlos cada diez minutos, y seguir sirviendo HTML prerenderizado. En Next eso es una función async en un server component y dos líneas:

export const getServiceStatus = unstable_cache(
  async () => Promise.all(projectsConfig.map(probe)),
  ["service-status"],
  { revalidate: 600, tags: ["service-status"] },
)

Astro tiene respuestas para esto, no es que no pueda. Pero en Astro era más artesanal, y sobre todo era una pieza más que iba a tener que sostener yo.

Acá hay un detalle que casi me come vivo, y lo dejo escrito porque es exactamente el tipo de cosa que no avisa. Las probes usan cache: "no-store". Un fetch no-store sin cachear saca la ruta del prerender estático: / y /es pasaron de a ƒ sin un solo warning, y cada visita disparaba ocho requests salientes. El unstable_cache de arriba no es una optimización, es lo que sostiene que la página siga siendo estática. Desde entonces miro la tabla de rutas del build después de tocar cualquier cosa del hero.

Las otras dos razones, menos técnicas y más honestas

El sitio tiene dos idiomas. Español e inglés, en rutas separadas, con canonical y hreflang correctos en cada página. Las dos herramientas lo hacen. Con los route groups de Next me quedó un solo árbol de componentes y dos layouts, y la parte de metadata que más me importaba — que ninguna página herede el canonical del padre y termine mandando a Google a la URL equivocada — la resolví en una función de veinte líneas por la que pasan todas las páginas.

Y la razón que en la práctica pesó más: los cuatro productos que tengo en producción son Next. Cuando el sitio comparte framework con lo que trabajo todos los días, cada cosa que aprendo en uno sirve en el otro. Al elegir Astro, este sitio se convertía en el único lugar del stack donde las cosas se hacen distinto — y es justo el proyecto al que menos horas por semana le voy a dedicar. Ese es el que menos conviene que sea especial.

Cuándo elegiría Astro

No quiero que esto se lea como que Next ganó en general, porque no es lo que pasó.

Si esto fuera un blog de contenido y nada más — posts, tal vez una landing, sin datos en vivo, sin dashboard atrás — iría con Astro sin pensarlo mucho. El modelo de islas es más honesto para ese problema: mandás cero JavaScript porque no hay ninguno que mandar, no porque lo optimizaste bien. Y hoy lee markdown, MDX y contenido remoto sin que tengas que armar el pipeline vos.

Lo que cambió mi caso fue la tabla de estado. En el momento en que la home empezó a medir servicios reales, dejé de tener un sitio de contenido.

Lo que me llevo

Medí antes de decidir, y la medición me dio ganador a Astro. Elegí lo otro igual, porque la métrica en la que ganaba ya estaba saturada en este sitio: los dos daban 100.

Los frameworks compiten en el eje del que más se habla. Las decisiones se terminan tomando en los otros: qué necesita hacer el proyecto de acá a un año, y qué stack ya sabés sostener cuando algo se rompe un domingo.