Todos los artículos

La odisea de la lentitud en iPhone: cómo 2 días de investigación terminaron en 3 líneas de CSS

Post-mortem. Cómo pasamos ~2 días persiguiendo un problema de "carga lenta en iPhone" por medio proyecto —servidor, base de datos, middleware, arquitectura multi-tenant, Service Worker— para que al final lo resolviera un cambio de 3 líneas de CSS.


TL;DR

AspectoDetalle
Síntomablackpass.mx cargaba bien en Android y escritorio. En iPhone (iOS Safari) tardaba 12–26 segundos en "terminar de cargar", con la barra de progreso pegada, aunque el contenido se veía casi al instante.
Lo que creíamosBase de datos lenta (Neon), render dinámico, cacheComponents, el proxy inyectando headers, el bundle pesado, el Service Worker.
Lo que erafilter: blur de radio gigante (blur-[100px]..blur-[150px]) sobre elementos grandes, usado para los "glows" de fondo. WebKit/iOS colapsa rasterizando eso.
La soluciónReemplazar blur-[Npx] + color sólido por radial-gradient(circle, color 0%, transparent 70%) en style. Mismo aspecto, coste de render ≈ 0.
Commitdfe65fa — perf(ui): reemplaza los glows blur-[Xpx] gigantes por radial-gradient
Resultado"el cambio fue instantáneo, cargaba super rápido en iphone"

El síntoma exacto

  • Android / Chrome / escritorio: perfecto. load en ~400 ms.
  • iPhone / iOS Safari (iOS 18.7, Tijuana):
    • TTFB ~30 ms — excelente
    • First Contentful Paint / LCP ~75–670 ms — excelente
    • CLS = 0 — perfecto
    • load a los 12 000–26 000 ms — roto
    • Todo ese tiempo era hueco muerto entre "DOM interactivo" (~0,3 s) y el evento load.

El usuario lo describía así, y con razón:

"cuando entro directamente a blackpass.mx/fistorsport tarda mucho no sé por qué"

"si tarda demasiado no sé por qué... o retirar el cache components o algo ya que si no queda me van a correr"


Contexto técnico

  • Next.js 16.3.4 (fork/canary — "This is NOT the Next.js you know"). El middleware se llama Proxy (proxy.ts). next lint y la opción eslint fuera. revalidateTag(tag, profile) con 2º argumento obligatorio.
  • Neon Postgres (endpoint -pooler, us-east-1), funciones Vercel en iad1. Autosuspend de Neon → cold starts.
  • App originalmente multi-tenant (venues por subdominio, superadmin, proxy que reescribía rutas). El portal público de cada venue colgaba de /[venue].
  • La ruta / iba rápida. Las de venue (/[venue]) tardaban. Esa asimetría fue la pista que nos llevó por el camino equivocado durante días.

La cronología del sufrimiento

Acto 1 — "Debe ser la base de datos / el render dinámico"

/ era estática y rápida; /[venue] pegaba a la BD en request time y no se prerenderizaba. Parecía obvio.

  • ❌ Quitar await connection() (que fuerza render dinámico por request).
    • El usuario: "no es el await connectio porque así funciona otro proyecto. debe de haber algo más profundo". Tenía razón.
  • ➕ generateStaticParams para prerenderizar los venues.
  • ➕ Promise.all para paralelizar las queries.
  • ➕ Cookie bp_session_hint (no httpOnly) para que el cliente supiera si hay sesión sin llamar a /api/auth/me en cada página pública anónima.
  • Commit 1518fae. Ayudó al servidor. No movió la aguja en iPhone.

Acto 2 — "Debe ser cacheComponents"

  • ❌ Quitar cacheComponents: true (canary) y migrar a ISR clásico: unstable_cache(fn, [keys], { tags: ['public'], revalidate: 3600 }) + revalidateTag('public', 'max') + export const revalidate.
  • Commit f2a2be8. El build quedó más simple y predecible. iPhone seguía igual.

Acto 3 — "Debe ser el proxy"

proxy.ts devolvía NextResponse.next({ request: { headers } }) inyectando un x-pathname que nadie leía. Modificar los headers del request en el proxy impide que Vercel sirva el HTML estático/ISR desde el CDN: obliga a re-renderizar en origen en cada visita.

  • ❌ Fast-path: si no es subdominio y no es /admin|/dashboard|/login → return NextResponse.next() a secas.
  • Commit b81e6ee. Correcto y necesario. No era esto tampoco.

Acto 4 — "Reduzcamos el proyecto a la mínima expresión"

Si no encontramos qué sobra, quitamos todo lo que sobra.

  • Multi-tenancy fuera. Superadmin fuera. Subdominios fuera. proxy.ts borrado. Un solo venue servido en /. [venue] plegado a la raíz.
  • Commit 4dc5ba0. next build verde. Arquitectura limpísima.
  • iPhone: seguía tardando 25 s.

En este punto ya habíamos reescrito medio proyecto y el problema no se había movido ni un milisegundo.

Acto 5 — "El peso está en el cliente"

  • El servidor no era el cuello de botella: / se prerenderiza estático, sin middleware ni BD en runtime.
  • ➖ framer-motion fuera del bundle de las páginas públicas (se desacopló BENEFIT_ICONS a su propio módulo, toasts por CSS, install-app con next/dynamic({ ssr:false })).
  • ➕ next/image para las imágenes de Blob. sharp con resizes más chicos.
  • Commit 93b2c52. El JS de / bajó de 230 KB → 186 KB gz. Bien, pero el load en iPhone seguía a 25 s.

Acto 6 — Instrumentación (y el bug del guion bajo)

No podíamos ver el teléfono del usuario, así que montamos telemetría:

  • components/client-diag.tsx: baliza RUM que manda con sendBeacon a /api/diag → UA, conexión, pantalla, timings de Navigation Timing, FCP, LCP, CLS, recursos.
  • El endpoint solo hace console.log('[DIAG] {...}'). Se lee con vercel logs blackpass.mx --json | grep '[DIAG]'.

Primer palo: la carpeta era app/api/_diag/. En Next, _ = carpeta privada, no enrutada. /api/_diag no existía; los beacons caían en app/[username]/[folio] (que devolvía 200 con la página renderizada) y el console.log nunca corría. Por eso logs: [] en Vercel.

  • Commit 30350bc: git mv app/api/_diag app/api/diag.
  • Commit 10e5fdf: ?diag=1 fuerza el beacon (para reprobar desde el mismo teléfono sin pestaña privada).

Segundo palo: el MCP de Vercel y get_runtime_logs no muestran el contenido de console.log. Solo vercel logs --json (CLI) lo saca.

Acto 7 — Los datos por fin

Con la baliza funcionando, esto es lo que mandó el iPhone del usuario (producción, deploys 10e5fdf y 56333f2):

CargaDOM listoFCP/LCPlongTasksloadTTFB
A0,57 s0,67 s0 ms11,8 s0
B0,25 s0,28 s0 ms25,9 s0,1 s
C——0 ms38,9 s23,7 s
Mac (control)0,34 s0,47 s0 ms0,42 s0,17 s

Lectura de esas cifras (que resultó ser parcialmente errónea):

  • longTasksMs: 0 → "no hay nada bloqueando el hilo". FALSO. Safari no soporta la API longtask, así que ese campo siempre sale 0 ahí. Lo teníamos hasta comentado en el código y aun así nos apoyamos en él. Error de bulto.
  • El hueco DOM→load sin recursos lentos → "algo mantiene load colgado". Cierto.
  • Carga C con TTFB de 23,7 s en una página estática → eso sí es red o Service Worker, no puede ser CSS. Nos ancló al SW.

Acto 8 — La teoría del Service Worker

public/sw.js tenía una rama stale-while-revalidate para estáticos que devolvía la caché al instante pero lanzaba event.waitUntil(fetch(...)) para revalidar en 2º plano. En iOS/WebKit, un waitUntil pendiente dentro de un handler fetch mantiene el evento load de la página en pausa hasta que ese fetch termina o hasta que WebKit lo mata por watchdog (~25 s).

Encajaba precioso: solo iPhone, sw.controlled: true, FCP/LCP intactos.

  • Commit 3a85bd3: rama de estáticos → CacheFirst puro, sin waitUntil. Borrado handleNavigate() muerto y el C_PAGES sin declarar. Versión de caché v20260904 → v20260908.
  • El usuario, con toda la razón:

    "no puedes que el sw se muera solo versionando cada uno de ellos ya que los usuarios no podrán hacer algo tan complejo"

  • Commit 56333f2: versión de caché derivada sola de VERCEL_GIT_COMMIT_SHA (cero strings a mano), registro del SW inmediato (no en load), y recarga única al cambiar de controlador para que los clientes viejos se curen solos.

Resultado: el load bajó de ~25 s a ~12 s en una carga… y volvió a 26 s en la siguiente. Y apareció la carga con TTFB de 23,7 s. El SW seguía metiendo ruido.

Acto 9 — Fuera el Service Worker

Dos rondas de parches al SW y iOS seguía sin cooperar. Decisión: quitarlo.

  • Commit 6f7e9ae: pwa-register.tsx ya no registra nada; solo desregistra cualquier SW previo y borra sus cachés. También se quitaron <SpeedInsights/> y <Analytics/> del layout (features de pago no activadas en el proyecto → scripts inútiles y sospechosos de dejar peticiones colgadas).
  • Se pierde: modo offline y el prompt de instalación en Android. "Añadir a inicio" en iOS sigue funcionando (no necesita SW).

La solución superficial (la que de verdad lo arregló)

Mientras tanto, en otra sesión paralela, se atacó lo que resultó ser la causa real:

Problema: rendimiento deficiente en iOS Safari por filter: blur grande.

Causa: desenfoques CSS de radio enorme (blur-[100px], blur-[120px], blur-[140px], blur-[150px]) sobre elementos grandes, para los "glows" de fondo. WebKit en iOS tiene un cuello de botella severo rasterizando blurs de gran radio sobre superficies grandes — cada blur fuerza una composición costosa que el motor gráfico de iOS no optimiza bien.

Solución: reemplazar blur + color sólido por un radial-gradient en style. Efecto visual casi idéntico, sin el coste de render del filtro. El gradiente va del color original en el centro (0 %) a transparente en el borde (70 %). Se mantienen pointer-events-none y se ajusta opacity si hace falta.

El diff, en esencia

- <div className="fixed top-1/3 left-1/2 -translate-x-1/2 -translate-y-1/2
-                 w-[28rem] h-[28rem] rounded-full
-                 bg-cyan-500/15 blur-[130px] pointer-events-none" />
+ <div
+   className="fixed top-1/3 left-1/2 -translate-x-1/2 -translate-y-1/2
+              w-[28rem] h-[28rem] rounded-full pointer-events-none"
+   style={{ background: 'radial-gradient(circle, rgba(6,182,212,0.15) 0%, transparent 70%)' }}
+ />
  • El alfa de Tailwind (/15, /10) se hornea en el rgba().
  • Se cambiaron 10 elementos decorativos de "iluminación ambiental" en app/loading.tsx, app/[username]/loading.tsx, app/[username]/[folio]/loading.tsx, app/page.tsx, app/login/page.tsx, app/offline/page.tsx, components/ambassador-profile-view.tsx, components/folio-pass-view.tsx, components/venue-profile-card.tsx.
  • Aspecto sin cambios en Android/escritorio.

Commit dfe65fa. Y el veredicto del usuario:

"lo que pasa es que cambié eso y el cambio fue instantáneo, cargaba super rápido en iphone"


Por qué nos costó tanto verlo

  1. El orden de los commits. Las trazas DIAG de 25 s que analizamos eran de builds que todavía tenían los blurs gigantes; el fix dfe65fa llegó después. O sea, esas trazas reflejaban blur + SW a la vez, y se le atribuyó casi todo al SW.
  2. longTasksMs: 0 mal interpretado. Safari no soporta longtask; el 0 no significaba "no hay bloqueo de render", significaba "no medido". El coste del blur no aparecía en ninguna métrica que estábamos mirando.
  3. El SW metía ruido real. La carga con TTFB de 23,7 s existía y no era CSS, así que reforzaba la pista equivocada. (Pudo ser un cold start puntual del SW despertando; ya da igual.)
  4. La asimetría / vs /[venue] nos convenció desde el minuto uno de que era un problema de servidor/arquitectura, cuando en realidad las páginas de venue simplemente tenían más elementos con blur que /.
  5. No teníamos un iPhone delante. Todo el diagnóstico fue por telemetría remota y logs. Un solo Timeline/Web Inspector de Safari conectado por cable habría mostrado el coste del filter: blur en 30 segundos.

Estado final

Rama single-venue, en orden:

CommitQué hace¿Necesario?
1518faeSin connection(), generateStaticParams, Promise.all, hint cookieSí — buena higiene
f2a2be8Sin cacheComponents → ISR clásicoSí — más simple
b81e6eeProxy sin inyección de headers muertaSí — habilita CDN
4dc5ba0Reducción a single-venueSí (decisión de producto)
93b2c52Sin framer-motion en el bundle público + next/imageSí — bundle más ligero
30350bc 10e5fdf 53f026eInstrumentación DIAGTemporal — se puede quitar
3a85bd3 56333f2Parches al Service WorkerSuperados por 6f7e9ae
6f7e9aeService Worker retirado + sin <SpeedInsights/>/<Analytics/>Decisión abierta (ver abajo)
dfe65fablur → radial-gradient — el fix realSí. Este era.

Decisión pendiente: ¿vuelve el Service Worker?

Ahora que el culpable real era el blur, el SW podría volver (da offline + prompt de instalación en Android). Recomendación: dejarlo fuera por ahora, confirmar que producción va rápido, y si el offline llega a importar, meter un SW mínimo — sin la rama SWR con waitUntil, sin registrar antes de load.


Lecciones

  • En perf de iOS Safari, sospecha del filter: blur / backdrop-filter de radio grande ANTES que de casi cualquier otra cosa. Es el error más común y más caro, y no aparece en Navigation Timing ni en Resource Timing.
  • longtask no existe en Safari. No uses ese 0 como prueba de nada.
  • Conecta el teléfono por cable y abre el Web Inspector. Media hora de telemetría remota < un Timeline de Safari de 30 segundos.
  • Ordena los experimentos por costo de descarte, no por elegancia. Comentar un <div> de glow y recargar cuesta 20 segundos. Reescribir la arquitectura multi-tenant, dos días.
  • Cuando el usuario dice "debe haber algo más profundo", puede que lo haya — pero "más profundo" no siempre significa "más abajo en el stack". A veces es un <div> decorativo.
— vistas