Acelerar Safari móvil: dos bugs, un solo síntoma
El informe tenía cuatro palabras: tarda muchísimo, a veces no carga. Resultó ser dos bugs sin relación disfrazados de una sola queja: uno en cómo se entregaban las páginas y otro en lo que le hacían al móvil una vez llegaban. Aquí está la campaña entera, incluida la decisión que revertimos veintitrés días después de tomarla.
En esta página
Todo informe de rendimiento que llega de una persona real llega comprimido. El nuestro decía que el sitio tardaba muchísimo en un iPhone y que a veces no cargaba en absoluto. Esa frase no contiene ningún diagnóstico, y la forma más rápida de tirar una semana es tratarla como un solo bug: ponerse a borrar animaciones porque la página parece pesada, o ponerse a añadir cabeceras de caché porque la página parece lenta. El trabajo de rendimiento en Safari móvil solo converge cuando partes el síntoma en dos: cuánto espera el móvil a recibir bytes, y qué hace el móvil una vez los tiene.
Medimos ambas cosas, encontramos una causa raíz distinta en cada una y las arreglamos en dos commits el mismo día. La mitad de la entrega era un bug estructural en nuestro árbol de rutas de Next.js que hacía que todas las páginas localizadas se renderizaran de forma dinámica. La mitad del runtime era acumulación: unas noventa animaciones infinitas, un escenario WebGL armándose en hardware al que nunca debió pedírsele, y el núcleo de animación de framer-motion viajando en el bundle inicial de la ruta de inicio.
- 250–950 ms → 3–8 ms
- TTFB de la portada
- 1.12 MB → 468 KB
- HTML de la portada
- 7 → 345
- rutas prerrenderizadas
- −91 KB
- JS inicial de la portada
El resultado en los dos frentes, medido el 2026-07-26 entre los commits de entrega y de runtime.
Todas las rutas de idioma eran dinámicas
El número de entrega ya era feo antes de que la red entrara en escena. El TTFB de la portada iba de 250 a 950 ms en localhost, con diez o veinte consultas a Supabase sin cachear por vista. En un portátil, en loopback, con la base de datos caliente. Fuera lo que fuese lo que estaba viviendo el móvil, era eso más todo lo que añade una ida y vuelta por red móvil.
La causa no eran las consultas. Era que a ninguna página se le permitía ser estática. Nunca habíamos activado el renderizado estático en next-intl y —la parte que de verdad importaba— nuestro layout raíz resolvía el idioma activo a partir del estado de la petición llamando a getLocale(). Una lectura en tiempo de petición dentro de un layout contamina todas las rutas por debajo. Next.js concluyó correctamente que nada bajo ese layout podía prerrenderizarse, así que todas las rutas de idioma se renderizaban en cada petición, se servían con no-store y nunca llegaban a ser cacheables en el CDN. El abanico de consultas al CMS corría entonces en cada vista, porque no había nada donde cachearlo.
El arreglo no fue una cabecera de caché. Fue borrar app/layout.tsx.
La API más reciente de Next, next/root-params, existe exactamente para esta forma de problema, y aquí no aplica: [locale] no es un parámetro raíz mientras haya un layout por encima. La única manera de que el segmento de idioma sea la cima del árbol es que no haya nada encima, lo que significa renunciar por completo al layout raíz compartido. Y eso hicimos. Ya no hay ningún app/layout.tsx en el repositorio. En su lugar hay cuatro layouts raíz, uno por familia de rutas, cada uno renderizando un <RootShell> compartido que posee el marcado de <html> y <body> y todo lo global: [locale], más (admin), (admin-auth) y pitch, los tres últimos con locale="en" fijo, porque nada en una consola de administración necesita una tabla de traducciones. global-error.tsx conserva su propio <html>, como debe ser.
La otra mitad de la activación va página a página: setRequestLocale tiene que llamarse en todos los componentes de servidor que renderizan una página localizada, y ahora lo llamamos en 34 archivos. Este es un invariante genuinamente malo: es invisible, es silencioso cuando se incumple, y una página que se lo olvida no se rompe, simplemente vuelve a ser dinámica sin decir nada y se lleva su TTFB por delante. Escribimos la regla en los comentarios de los archivos de los que es más probable que se copie una página nueva, y la escribimos también aquí: toda página nueva debe repetir setRequestLocale.
Una ruta se quedó dinámica a propósito. La hoja de ruta interactiva lee useSearchParams sin un límite de Suspense, lo que la clava en renderizado dinámico; de todos modos es una superficie noindex, y forzar el asunto habría implicado reestructurar una página de la que nadie se quejaba. Fijarlo explícitamente era mejor que dejarlo accidental.
Cachear la base de datos, no la página
El prerrenderizado arregla el camino de petición, pero nuestro contenido es editable: el texto de las secciones, los metadatos de página y el contenido de las filas salen de Supabase a través de una pequeña capa de CMS. Una matriz de compilación de seis idiomas que golpee la base de datos una vez por consulta y por página cambiaría una petición lenta por una compilación lenta. Así que los tres caminos de lectura se metieron detrás de unstable_cache — getSectionRow bajo ["cms-section"], getRowsForLocale bajo ["cms-rows"], getPageMetaRow bajo ["cms-page-meta"], todos con { revalidate: 300, tags: ["cms-content"] }. Un renderizado de página, y la compilación entera de seis idiomas, ahora golpean Supabase una vez por consulta distinta cada cinco minutos en vez de una vez por petición.
Un detalle de esa frase sostiene el resto y es fácil de perder en una refactorización: el CMS usa un cliente público de Supabase sin cookies. Llamar a cookies() dentro de unstable_cache lanza una excepción en Next 15+, así que un cliente que lea la sesión no puede cachearse en absoluto. Lo sabemos porque el mismo error ya había matado en silencio, meses antes, nuestras anulaciones de traducción guardadas en la base de datos, detrás de un catch pelado. Los editores tampoco esperan los cinco minutos: las acciones de escritura del panel emparejan revalidatePath con revalidateTag("cms-content"), y las anulaciones de i18n llevan su propia etiqueta "i18n-overrides".
Los números de entrega se movieron en bloque. La compilación pasó de 7 rutas prerrenderizadas a 345, sobre 497 páginas estáticas. La portada se sirve con x-nextjs-cache: HIT y un TTFB en el rango de 3 a 8 ms — números de CDN, porque ahora es un artefacto de CDN. El propio HTML bajó de 1,12 MB a 468 KB, aunque no del todo por el prerrenderizado, lo que nos lleva a la decisión que revertimos.
La misma palanca, veintitrés días después
El 3 de julio activamos inlineCss en Next. El razonamiento era sólido y la evidencia era real: nuestra hoja de estilos pesaba 38,6 KB, era la última petición bloqueante del renderizado de la página y costaba unos 530 ms de FCP en móvil. Incrustarla quitaba una ida y vuelta del camino crítico. En el mismo cambio apretamos browserslist — Chrome, Edge y Firefox 111+, Safari e iOS 16.4+ — porque una base moderna es lo que hace pequeña a una hoja de estilos pequeña.
El 26 de julio lo desactivamos. Habían cambiado dos cosas. La hoja de estilos había crecido hasta unos 320 KB en bruto (unos 40 KB comprimidos); y el CSS incrustado viaja dentro del HTML de cada navegación, sin forma de que el navegador lo cachee entre páginas. Cuando cada página era un renderizado dinámico, el trato era justo: el HTML era incacheable de todas formas, así que el CSS no perdía nada por acompañarlo. En cuanto las rutas se prerrenderizaron y se cachearon en el CDN, la aritmética se invirtió: una hoja de estilos cacheable, descargada una vez, gana a 320 KB reenviados dentro de cada documento.
La misma palanca, la conclusión opuesta, veintitrés días después — y ambas decisiones fueron correctas en su contexto.
Tenemos esta pareja presente cada vez que alguien nos cita una regla de rendimiento. inlineCss no es bueno ni malo. Es una apuesta sobre la relación entre el tamaño de tu hoja de estilos y la cacheabilidad de tu HTML, y esa relación la cambiamos nosotros mismos.
Noventa animaciones que nadie estaba mirando
La mitad del runtime no era un error; eran tres meses de errores pequeños. Animaciones CSS infinitas, capas de mezcla que abarcaban toda la página, un escenario WebGL y el núcleo de framer-motion cargado con avidez en el bundle inicial: cada cosa defendible cuando aterrizó, y en conjunto un fundidor de móviles. La medida más clara: para cuando has bajado hasta la llamada a la acción final de la portada, hay unas noventa animaciones infinitas vivas a la vez. Al compositor le da igual que ochenta estén muy fuera de pantalla.
La compuerta es deliberadamente tonta. SectionViewportGate es un componente de cliente que observa cada #main-content > section con un margen de raíz de 160 px y alterna un atributo data-offscreen; el CSS hace el resto con animation-play-state. Los cambios de atributo ocurren fuera de React a propósito: encaminar noventa callbacks de observador a través de actualizaciones de estado habría costado más que las animaciones que intentábamos pausar.
Junto a eso echamos mano de content-visibility: auto, que deja al navegador saltarse la maquetación y el pintado de las secciones a las que no ha llegado el desplazamiento. Ese cambio se publicó con un bug que merece contarse.
/* nth-of-type, not nth-child. Three JSON-LD <script> children
precede the sections inside <main>. */
section:nth-of-type(n + 3) {
content-visibility: auto;
contain-intrinsic-size: auto 800px;
}Una pseudoclase, y toda una clase de CLS muy desconcertante. El hero no debe coincidir jamás con esta regla.
Escrito como nth-child(n+3), el selector cuenta todos los hijos de <main> — y hay tres etiquetas <script> de JSON-LD por delante. El desfase se corrió tres posiciones y la regla cayó sobre todas las secciones, hero incluido. content-visibility implica contain: size, así que el hero de 100svh se maquetó primero con su altura de marcador de posición de 800 px y se corrigió un instante después: un reflujo en carga y un desplazamiento de maquetación en la primerísima pantalla, provocados por un cambio cuyo propósito entero era abaratar esa primera pantalla. nth-of-type cuenta solo elementos <section>. Una palabra, y el hero vuelve a quedar fuera de alcance.
Todos los iPhone son dispositivos de gama baja
Nuestras superficies WebGL viven detrás de un detector de nivel de calidad. detectTier lee cuatro señales — pointer: coarse, hardwareConcurrency ?? 4, deviceMemory ?? 4 y la dimensión menor de la pantalla. Un dispositivo es un móvil si tiene puntero grueso y su dimensión mínima está por debajo de 700 px, y un móvil solo asciende a medio con al menos seis núcleos y al menos seis gigabytes de memoria declarada.
| Nivel | Cómo llega un dispositivo hasta él | Tope de DPR |
|---|---|---|
| low | Cualquier móvil que no supere el ascenso de 6 núcleos / 6 GB — es decir, todos los iPhone | 1.25 |
| medium | Los escritorios por defecto, los móviles ascendidos y todo renderizado de servidor | 1.5 |
| high | Puntero fino con los núcleos y la memoria que lo respalden | 2 |
La tabla de niveles, y la razón de que el valor por defecto en SSR sea medium: el servidor no puede saberlo, y medium es la conjetura honesta.
Safari no expone deviceMemory en absoluto. Por tanto, el valor por defecto ?? 4 incumple la puerta de ≥6 de forma incondicional y todos los iPhone caen en el nivel bajo. Pasamos una hora tratándolo como un bug de detección antes de concluir que era la respuesta correcta alcanzada por accidente: Safari en iOS tiene el tope de contextos WebGL más bajo de todos los navegadores que soportamos y es el más dispuesto a matar una pestaña bajo presión de memoria. Así que ahora el escenario devuelve antes de montarse cuando tier === "low", lo que significa que su chunk ni siquiera se descarga. El contexto WebGL más barato es el que decides no crear.
El resto de la dieta de runtime es una lista de pequeñas amputaciones, cada una de las cuales elimina trabajo del que los dispositivos táctiles no podían beneficiarse de entrada:
- El campo de código del hero se renderiza estático en táctil, sin ningún bucle de fotogramas. No hay cursor al que perseguir, y la lente errante costaba un redibujado de canvas a pantalla completa a 60 fps en la primera pantalla del móvil. Su camino de redimensionado también se salta la reconstrucción de ~2.600 llamadas a
fillText, porque en iOS el colapso de la barra de direcciones cuenta como un redimensionado. - La superposición de la lente de código está limitada a
pointer: fine. Es un canvas conmix-blend-modefijo: gravaba cada fotograma de desplazamiento en los móviles para producir un efecto que el táctil no puede activar. - Los orbes del trabajo destacado cambiaron
filter: blur(28px)por degradados radiales de tres paradas ya horneados. Animan su escala, así que la gaussiana se recalculaba en cada fotograma; un degradado que se ve igual no cuesta nada reescalarlo.will-changeahora está limitado a punteros capaces de hover, y el táctil aplanapreserve-3d. - Los vídeos se recodificaron a 960×540, lo que los llevó de 11,2 MB a 5,1 MB. Se renombraron a
*-2.mp4para respetar nuestro contrato de nombres con caché inmutable — los recursos se sirven conmax-age=31536000, immutable, así que un archivo cambiado necesita un nombre cambiado, y a cambio las visitas repetidas se ahorran unos 100 MB.
LazyMotion, al segundo intento
El asunto más largo de la campaña empezó con un informe de bundle que culpaba a 63 KB de JavaScript sin usar. Fuimos a mirar y resultó que el chunk era react-dom, que no es algo que se recorte. La grasa de verdad era framer-motion: importar motion en cualquier sitio arrastra el núcleo de animación con avidez, y nuestra portada lo importaba en casi todas sus secciones.
El intento del 3 de julio envolvió cada sección en su propio LazyMotion con strict activado, y llevó la transferencia de la portada de 284 KB a 269 KB. El modo estricto es lo que hace real el ahorro: prohíbe los componentes motion.* normales, así que una sola importación despistada no puede volver a colar el núcleo sin que nadie se entere. También tiene dientes: tuvimos que matar el animate() imperativo de framer dentro de nuestro hook de números contadores y reescribirlo como un pequeño easeOutQuart sobre requestAnimationFrame, porque esa única importación bastaba para devolver el núcleo de animación al grafo crítico del LCP. Y hubo que renombrar una variable de bucle llamada m en el componente de la órbita de gravitas, porque tapaba el espacio de nombres m de framer.
Y luego se revirtió. La reversión es limpia y completa, con un cuerpo de commit autogenerado y sin motivo declarado, lo que significa que lo más honesto que podemos decir es que no sabemos qué se rompió. Tenemos opiniones; no tenemos pruebas. Esa laguna es su propia lección sobre higiene de commits, y es la razón de que el segundo aterrizaje tomara otra forma.
// One shared provider. The feature bundle is loaded asynchronously,
// so it streams in after first paint instead of blocking it.
const loadDomAnimation = () =>
import("framer-motion").then((m) => m.domAnimation);
<LazyMotion features={loadDomAnimation} strict>La forma definitiva: un único MotionProvider con carga asíncrona de funcionalidades, y el modo estricto manteniendo honestos a todos los consumidores.
La versión del 26 de julio sustituyó los envoltorios por sección por un único MotionProvider compartido, y solo entró después de auditar a mano todos los consumidores de la ruta de inicio. Son tres: el escaparate de juegos, el carrusel del Laboratorio y el envoltorio de inclinación de tarjetas. La regla de la casa ya está escrita — las secciones de la portada usan m.* bajo el proveedor, nunca motion.* — y el modo estricto la impone en tiempo de ejecución en lugar de en tiempo de revisión.
Merece la pena nombrar una trampa porque es invisible en una revisión. CardTilt mantiene un tipo de elemento constante: condiciona su comportamiento a nivel de estilo y jamás cambia un div por un m.div. Cambiar el tipo de elemento en el cliente remontaría todo el subárbol después de la hidratación — lo que, para una tarjeta que contiene un escenario de canvas, significa desmontar y reconstruir justamente aquello que intentabas abaratar. Condicionar con estilos es más feo y es correcto.
El JavaScript inicial de la portada acabó en 1.365 KB en bruto, frente a los 1.456 KB anteriores, sin ningún chunk que lleve el núcleo de movimiento.
El último ulp
El artefacto más extraño de la campaña no tenía nada que ver con la velocidad. Uno de nuestros pósteres de juego —el de riftline— calcula sus coordenadas SVG con trigonometría, y React 19 compara los atributos renderizados en el servidor con los renderizados en el cliente durante la hidratación. La libm de Node y la de Chromium discrepan en la última unidad en el último lugar. Las dos cadenas difieren en un dígito que ningún humano verá jamás, React lo detecta, y te llevas un aviso de hidratación por una imagen estática. El arreglo es cuantizar las coordenadas antes de que se conviertan en atributos, que es otra forma de decir: si dos implementaciones tienen que coincidir en un número, no les pidas que coincidan en todo él.
Ambos commits se publicaron con tsc, eslint y la compilación de producción en verde, y con los 27 tests de extremo a extremo preexistentes pasando. Esa última comprobación importa más de lo que parece: casi todos los cambios de aquí eliminan trabajo, y eliminar trabajo es la forma más fácil de eliminar una funcionalidad por accidente.
Lo que le diríamos a quien empiece la misma campaña es que la división es todo el método. Los bugs de entrega y los de runtime producen quejas idénticas y no comparten casi ningún arreglo; aquí la mitad de la entrega fue una decisión estructural sobre dónde vive el idioma en el árbol de rutas, y la mitad del runtime fueron noventa decisiones pequeñas. Ninguna se habría encontrado mirando fijamente a la otra.
El invariante con el que vivimos ahora es el que menos nos gusta. setRequestLocale tiene que aparecer en todas las páginas localizadas, y no falla nada cuando no aparece: la página simplemente deja de ser estática y toda la ganancia de entrega se va escapando en silencio, ruta a ruta. Si quieres ver la forma del sitio que pagan esos números, el Laboratorio es lo más pesado que servimos y la mejor prueba de si la dieta aguantó. La reconstrucción de la portada explica por qué hay tanto que renderizar de entrada, y seis idiomas en una sola base de código explica por qué el segmento de idioma estaba donde estaba.