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
| Aspecto | Detalle |
|---|---|
| Síntoma | blackpass.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íamos | Base de datos lenta (Neon), render dinámico, cacheComponents, el proxy inyectando headers, el bundle pesado, el Service Worker. |
| Lo que era | filter: blur de radio gigante (blur-[100px]..blur-[150px]) sobre elementos grandes, usado para los "glows" de fondo. WebKit/iOS colapsa rasterizando eso. |
| La solución | Reemplazar blur-[Npx] + color sólido por radial-gradient(circle, color 0%, transparent 70%) en style. Mismo aspecto, coste de render ≈ 0. |
| Commit | dfe65fa — 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.
loaden ~400 ms. - iPhone / iOS Safari (iOS 18.7, Tijuana):
TTFB~30 ms — excelenteFirst Contentful Paint/LCP~75–670 ms — excelenteCLS= 0 — perfectoloada 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 linty la opcióneslintfuera.revalidateTag(tag, profile)con 2º argumento obligatorio. - Neon Postgres (endpoint
-pooler,us-east-1), funciones Vercel eniad1. 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.
- ➕
generateStaticParamspara prerenderizar los venues. - ➕
Promise.allpara paralelizar las queries. - ➕ Cookie
bp_session_hint(no httpOnly) para que el cliente supiera si hay sesión sin llamar a/api/auth/meen 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.tsborrado. Un solo venue servido en/.[venue]plegado a la raíz. - Commit
4dc5ba0.next buildverde. 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-motionfuera del bundle de las páginas públicas (se desacoplóBENEFIT_ICONSa su propio módulo, toasts por CSS,install-appconnext/dynamic({ ssr:false })). - ➕
next/imagepara las imágenes de Blob.sharpcon resizes más chicos. - Commit
93b2c52. El JS de/bajó de 230 KB → 186 KB gz. Bien, pero elloaden 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 consendBeacona/api/diag→ UA, conexión, pantalla, timings de Navigation Timing, FCP, LCP, CLS, recursos.- El endpoint solo hace
console.log('[DIAG] {...}'). Se lee convercel 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=1fuerza 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):
| Carga | DOM listo | FCP/LCP | longTasks | load | TTFB |
|---|---|---|---|---|---|
| A | 0,57 s | 0,67 s | 0 ms | 11,8 s | 0 |
| B | 0,25 s | 0,28 s | 0 ms | 25,9 s | 0,1 s |
| C | — | — | 0 ms | 38,9 s | 23,7 s |
| Mac (control) | 0,34 s | 0,47 s | 0 ms | 0,42 s | 0,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 APIlongtask, 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→
loadsin recursos lentos → "algo mantieneloadcolgado". 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, sinwaitUntil. BorradohandleNavigate()muerto y elC_PAGESsin 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 deVERCEL_GIT_COMMIT_SHA(cero strings a mano), registro del SW inmediato (no enload), 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.tsxya 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: blurgrande.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 unradial-gradientenstyle. 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 mantienenpointer-events-noney se ajustaopacitysi 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 elrgba(). - 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
- 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
dfe65fallegó después. O sea, esas trazas reflejaban blur + SW a la vez, y se le atribuyó casi todo al SW. longTasksMs: 0mal interpretado. Safari no soportalongtask; 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.- 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.)
- 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 conblurque/. - No teníamos un iPhone delante. Todo el diagnóstico fue por telemetría
remota y logs. Un solo
Timeline/Web Inspectorde Safari conectado por cable habría mostrado el coste delfilter: bluren 30 segundos.
Estado final
Rama single-venue, en orden:
| Commit | Qué hace | ¿Necesario? |
|---|---|---|
1518fae | Sin connection(), generateStaticParams, Promise.all, hint cookie | Sí — buena higiene |
f2a2be8 | Sin cacheComponents → ISR clásico | Sí — más simple |
b81e6ee | Proxy sin inyección de headers muerta | Sí — habilita CDN |
4dc5ba0 | Reducción a single-venue | Sí (decisión de producto) |
93b2c52 | Sin framer-motion en el bundle público + next/image | Sí — bundle más ligero |
30350bc 10e5fdf 53f026e | Instrumentación DIAG | Temporal — se puede quitar |
3a85bd3 56333f2 | Parches al Service Worker | Superados por 6f7e9ae |
6f7e9ae | Service Worker retirado + sin <SpeedInsights/>/<Analytics/> | Decisión abierta (ver abajo) |
dfe65fa | blur → radial-gradient — el fix real | Sí. 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-filterde 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. longtaskno existe en Safari. No uses ese0como prueba de nada.- Conecta el teléfono por cable y abre el Web Inspector. Media hora de
telemetría remota < un
Timelinede 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.