De 435 ms a 150 ms: por qué esta web parecía estática y no lo era

14 min lectura
De 435 ms a 150 ms: por qué esta web parecía estática y no lo era

Durante meses, esta web estuvo construida como un sitio estático y se sirvió como una aplicación dinámica. Ninguna página llegaba desde la caché del CDN: cada visita desde España cruzaba el Atlántico hasta una función en Virginia, se regeneraba entera y volvía. El tiempo hasta el primer byte era de 435 ms estables, con picos de 1,2 s. Hoy las mismas páginas se sirven desde el borde en 140-190 ms.

Lo incómodo del caso es que nada parecía roto. El sitio cargaba, Google indexaba, el build decía «estático» y no había ni un error en los registros. La única prueba de que algo iba mal estaba en dos cabeceras HTTP que casi nadie mira. Este artículo cuenta las tres causas que encontramos, cómo se arreglaron, qué sigue sin arreglar y cómo comprobar en dos minutos si tu web tiene el mismo problema.

En resumen

  • Una sola línea dinámica contamina todo el sitio. Un headers() en el layout raíz saca del prerenderizado a todas las páginas que cuelgan de él, sin avisar.
  • Un revalidate corto con tráfico bajo equivale a no tener caché. Si entre dos visitas pasa más tiempo que el periodo de revalidación, todas las visitas pagan la regeneración.
  • Las cabeceras lo delatan: x-vercel-cache: MISS y cache-control: no-store en una página que debería ser estática significan que no estás sirviendo lo que crees.
  • La CSP por nonce cobra un peaje. Un nonce por petición obliga a renderizar en cada petición: seguridad y caché tiran en direcciones opuestas y hay que decidir ruta por ruta.
  • Un searchParams basta para dejar una ruta fuera de la caché. Nuestro /blog sigue así hoy: 474-640 ms frente a los 150 ms del resto.
  • Medir el TTFB con curl sin restar el handshake infla el número. En nuestras medidas, la conexión TLS son ~70 ms de los 150.

Qué es el TTFB y qué no es

El TTFB (time to first byte, tiempo hasta el primer byte) es lo que tarda el navegador en recibir el primer byte de la respuesta desde que pide la página. Incluye la resolución DNS, la conexión, el handshake TLS y el tiempo que el servidor tarda en generar la respuesta.

No es «la velocidad de la web». El usuario no ve nada cuando llega el primer byte: lo que percibe se parece más al LCP, que mide cuándo aparece el elemento principal de la pantalla. Lo que hace importante al TTFB es que es el suelo de todo lo demás: ningún truco de imágenes, fuentes o JavaScript va a recuperar medio segundo perdido antes de que el HTML haya empezado a llegar. Google lo trata como métrica de diagnóstico, no de experiencia, y recomienda mantenerlo por debajo de 800 ms.

Los 800 ms son el listón para no tener un problema. Una web estática servida desde un CDN cercano debería estar muy por debajo: entre 30 y 150 ms según la distancia al borde.

Cómo medirlo en dos minutos

No hace falta ninguna herramienta. Con curl:

curl -s -o /dev/null -w "ttfb=%{time_starttransfer}s tls=%{time_appconnect}s\n" https://tudominio.com/

Dos advertencias que cambian el resultado. La primera: la primera medición siempre miente, porque incluye DNS y conexión en frío. Lanza el comando tres o cuatro veces y quédate con las últimas. En nuestra home, la primera petición de una sesión da 1,46 s y las siguientes 141-167 ms: el mismo servidor, la misma página.

La segunda: time_starttransfer incluye el handshake. Por eso pedimos también time_appconnect — la diferencia entre ambos es el tiempo real del servidor. En nuestras medidas desde España, de los ~150 ms de TTFB, unos 70 son conexión TLS y unos 80 son servidor. Comparar un TTFB con handshake contra otro sin él es la forma más rápida de sacar una conclusión equivocada.

Y ahora lo que de verdad importa, que no es el número sino las cabeceras:

curl -s -D - -o /dev/null https://tudominio.com/ | grep -i -E "cache-control|x-vercel-cache|age:"

En un alojamiento con CDN (Vercel, Cloudflare, Netlify, Fastly: cambia el nombre de la cabecera, no el concepto) esto te dice si la página vino de la caché o si alguien tuvo que generarla:

Cabecera Qué significa
x-vercel-cache: HIT La respuesta salió de la caché del borde. Es lo que quieres.
x-vercel-cache: STALE Salió de la caché una copia caducada, y se está regenerando por detrás. Rápido para quien la pide, aceptable.
x-vercel-cache: MISS No había copia. Alguien ha ejecutado tu código para responder a esta visita.
cache-control: private, no-store Tu propio framework está prohibiendo que se cachee. Si la página es pública, es un error.
age: 1431 Segundos que lleva esa copia en la caché. Un age que nunca sube de cero es señal de que nada se está cacheando.

Nuestra medición de agosto fue exactamente esta: seis de seis URLs con MISS, todas con private, no-cache, no-store. Una web de contenido, sin ninguna zona privada en esas rutas, declarando que no se puede cachear nada.

Causa 1: una línea en el layout raíz

En Next.js con App Router, cada página se prerenderiza en el build salvo que algo la obligue a esperar a la petición. Leer cabeceras con headers(), leer cookies o leer searchParams son ese algo. Es razonable: si tu página depende de la petición, no se puede generar antes de que la petición exista.

Lo que no es evidente es que eso se hereda hacia abajo. Teníamos un headers() en el layout raíz para leer una cabecera propia (x-pathname, puesta por el middleware para saber en qué ruta estábamos). Una línea, en un fichero, por una funcionalidad menor. Como el layout raíz envuelve al sitio entero, todas las páginas pasaron a renderizarse en cada petición. El build seguía diciendo que el resultado era estático en la mayoría de rutas, pero en producción cada URL ejecutaba una función.

La pista que lo confirmó: en toda la web solo había una URL que devolvía HIT, y era un mockup HTML antiguo servido directamente desde public/. Lo único que no pasaba por el layout raíz era lo único que se cacheaba.

El arreglo fue mover todo el sitio público a un route group con su propio layout, sin headers(), y dejar el layout raíz reducido a lo mínimo. La ruta que necesitaba conocer el pathname lo obtiene ahora sin leer la petición. Coste: unas horas de reorganizar carpetas. Efecto: el sitio volvió a prerenderizarse de verdad.

Cómo comprobarlo en tu proyecto: busca headers(), cookies(), connection() y searchParams en tus layouts, empezando por el raíz. Cuanto más arriba esté, más caro sale.

Causa 2: revalidar cada minuto es no cachear

Arreglado lo anterior, las páginas se cacheaban… y seguían llegando con STALE y regenerándose sin parar. El motivo estaba en una constante: el fetch al WordPress que nos sirve de CMS llevaba revalidate: 60.

Sesenta segundos parece una elección prudente: el contenido nunca está más de un minuto desactualizado. Aquí está el error de razonamiento, y es de los que se cometen con las mejores intenciones:

Un periodo de revalidación solo sirve de algo si recibes varias visitas dentro de ese periodo. Si tus visitas están más separadas que el revalidate, cada visita encuentra la copia caducada y paga la regeneración. El resultado es idéntico a no tener caché, con el añadido de creer que la tienes.

Para una web con tráfico de agencia, no de medio de comunicación, 60 segundos equivalían a «nunca en caché». Y había un agravante: el bloque de últimos artículos aparece en todas las páginas, así que todas heredaban ese minuto. Páginas como /precios o /servicios, que no cambian en semanas, salían del build con un periodo de un minuto sin que nadie lo hubiera declarado.

Encima, la regeneración no era local. La cabecera x-vercel-id: cdg1::iad1 dice dos cosas: el borde que atendió la petición está en París (cdg) y la función que generó la página está en Washington (iad). Cada visita española que encontraba la copia caducada disparaba un viaje transatlántico de ida y vuelta.

El arreglo fue subir el periodo a 3600 s, una hora, en el fetch y en las diez páginas del blog que lo fijaban a 60. El precio es explícito y asumido: un artículo nuevo puede tardar hasta una hora en aparecer, y cualquier despliegue invalida todo de golpe. Si eso no vale para tu caso, la alternativa es invalidar bajo demanda con un webhook del CMS en lugar de esperar al reloj.

La región de las funciones, en cambio, se quedó en Virginia a propósito. Mover las funciones a Europa habría acelerado lo que ya no debería llegar al origen, y frenado lo que sí lo necesita: la base de datos de nuestro panel interno está en Phoenix, a 162 ms de España. Optimizar la región es elegir a quién beneficias.

Una nota que cuesta poco escribir y evita disgustos: este arreglo estuvo deshecho durante horas sin que nadie lo notara. Un commit posterior devolvió por accidente el fichero a revalidate: 60, y como el fichero fija el periodo de todo el sitio, el arreglo simplemente dejó de existir. Se detectó volviendo a lanzar el curl contra producción. Una optimización de caché que no se vuelve a medir después de desplegar es una optimización que no sabes si tienes.

Causa 3: la seguridad que cobra en rendimiento

La tercera causa no era un descuido, sino un conflicto real entre dos cosas que queríamos a la vez.

Nuestra política de seguridad de contenido (CSP) usaba un nonce: un valor aleatorio distinto en cada petición que se pone en la cabecera y en cada etiqueta <script>, de forma que el navegador solo ejecuta los scripts que lo llevan. Es una de las mejores defensas contra XSS reflejado. Y tiene una consecuencia estructural: si cada petición necesita un valor distinto, no hay HTML que se pueda reutilizar entre peticiones. Nonce y caché son incompatibles por definición, no por implementación.

El aterrizaje fue peor de lo previsto. Al recuperar el prerenderizado, el HTML de las páginas públicas ya no se generaba por petición, así que Next no podía estampar ningún nonce en sus scripts — pero la cabecera seguía exigiéndolo, con strict-dynamic. Bajo esa directiva, el navegador ignora el resto de la política y bloquea lo que no lleve nonce. Resultado: todos los chunks de JavaScript bloqueados, en todas las páginas, durante un día entero. React no hidrataba: la cabecera se quedaba transparente, el formulario de contacto no enviaba y el botón de WhatsApp no hacía nada.

Lo que más costó fue el diagnóstico, no el arreglo. curl funcionaba perfectamente y las llamadas fetch() también, porque ninguna de las dos cosas pasa por script-src. Todo el rastro apuntaba a otros sitios: al service worker, a las ráfagas de HTTP/2, al código del header. Ninguna era la causa.

El arreglo fue dejar de tratar todo el sitio igual y aplicar una CSP por tipo de ruta: la web pública, que es estática y no tiene sesiones, usa una política sin nonce; el panel y las páginas con sesión, que son dinámicas de todos modos, mantienen nonce y strict-dynamic. El nonce no se pierde donde de verdad protege algo.

El resultado, medido

Estas son las medidas de hoy contra producción, desde una conexión doméstica en España, con tres peticiones por URL descartando la primera:

URL Caché TTFB
/ HIT 141-167 ms
/servicios HIT 142-147 ms
/precios HIT 147 ms
/nosotros HIT 151-191 ms
Un artículo del blog STALE 166-196 ms
/blog MISS 474-640 ms

Frente a los 435 ms estables de agosto y al rango de 0,29-1,2 s que midió la auditoría de septiembre, con MISS en todas las URLs probadas. Recuerda restar el handshake: de esos 150 ms, unos 70 son conexión TLS, así que el tiempo de servidor está en torno a 80 ms.

Un punto de comparación útil, porque es el mismo contenido: el WordPress que nos sirve de CMS responde su API en 530-595 ms desde aquí. La diferencia entre consultar el origen y servir desde el borde es, redondeando, medio segundo por visita.

Lo que sigue roto: /blog

En la tabla hay una fila que desentona, y no está ahí por descuido. /blog sigue siendo dinámica: MISS, no-store y entre 474 y 640 ms, tres o cuatro veces el resto del sitio.

La causa es de manual y por eso merece estar en el artículo: la página lee searchParams, porque el índice está paginado con ?page=2. Leer los parámetros de la URL obliga a esperar a la petición, exactamente igual que headers(). Una decisión de producto de hace meses — paginar el blog — se tradujo, sin que nadie lo decidiera, en una decisión de arquitectura de caché.

Tiene arreglo: rutas de verdad (/blog/pagina/2) que se pueden prerenderizar una a una, o paginación en el cliente sobre datos ya cargados. Está en la cola, por debajo de cosas con más impacto. Lo contamos porque el objetivo de este artículo es que puedas reconocer el patrón, y el patrón se reconoce mejor con un ejemplo vivo que con uno resuelto.

Qué llevarte a tu web

  1. Lanza los dos curl de este artículo contra tu home y contra dos páginas interiores. Si ves MISS repetido o no-store en páginas públicas, tienes este problema.
  2. Busca las funciones dinámicas en tus layouts antes que en tus páginas: headers(), cookies(), searchParams. Cuanto más arriba, más caro.
  3. Compara tu revalidate con tu tráfico real. Si entre visitas pasa más tiempo que el periodo, tu caché es decorativa. Sube el periodo e invalida con un webhook cuando publiques.
  4. Mira de dónde se sirve. La cabecera de tu CDN suele decir en qué borde y en qué región se ejecutó. Si tus usuarios están en Europa y tu función en Virginia, eso son unos 100 ms de peaje por visita que no llega a la caché.
  5. Vuelve a medir después de cada despliegue. Estos arreglos son una línea, y una línea se deshace sola en cualquier merge.

Errores frecuentes

  • Fiarse de lo que dice el build. Que una ruta salga marcada como estática no garantiza que se sirva desde la caché en producción. La cabecera de respuesta es la única fuente de verdad.
  • Medir con una sola petición. La primera incluye DNS y TLS en frío y puede multiplicar por diez el resultado.
  • Optimizar imágenes antes que la caché. Las imágenes importan, pero no recuperan un TTFB alto: llegan después.
  • Bajar el revalidate «por si acaso». Casi siempre es el contrario de lo que conviene: menos periodo, menos caché, más coste.
  • Tratar seguridad y rendimiento como capas independientes. Una CSP por nonce y una página cacheada no pueden convivir; hay que elegir, ruta por ruta, con el motivo escrito.

Preguntas frecuentes

¿Cuánto debería ser un buen TTFB?

Google sitúa el umbral de «bueno» en 800 ms, pero eso es el listón para no tener un problema. Una página estática servida desde un CDN cercano debería estar entre 30 y 150 ms. Si tu web es de contenido y está por encima de 400 ms de forma estable, casi seguro es un problema de caché, no de servidor.

¿Por qué mi web va rápida a mí y lenta en las medidas?

Porque tu navegador tiene la conexión abierta, el DNS resuelto y medio sitio en caché local. Mide siempre con varias peticiones, en frío, y desde el país de tus clientes.

¿Next.js es lento?

No: está configurado como dinámico por accidente en muchísimos proyectos. El App Router prerenderiza por defecto, pero cualquier lectura de la petición —una cabecera, una cookie, un parámetro de URL— desactiva ese comportamiento hacia abajo en el árbol, y no avisa de forma llamativa.

¿Vale la pena bajar de 400 ms a 150 ms de TTFB?

Como ganancia percibida por el usuario, medio segundo en el peor caso: notable pero no transformador. Lo que sí cambia es el suelo de todas las demás métricas, el coste de las funciones ejecutadas y el comportamiento de los rastreadores, que penalizan los tiempos de respuesta altos reduciendo el ritmo de rastreo.

¿Esto solo pasa en Next.js y Vercel?

El mecanismo concreto sí es de Next.js, pero el patrón es universal: un fragmento dinámico que contamina páginas que podrían ser estáticas. Pasa en WordPress con un plugin que llama a session_start() en todas las páginas, en Laravel con una cookie emitida en cada respuesta, y en cualquier CDN con un Cache-Control mal puesto. Cambian los nombres de las cabeceras, no el diagnóstico.

¿Cómo sé si mi caché funciona sin acceso al servidor?

Con curl -D - o con la pestaña de red del navegador. Mira cache-control, la cabecera de caché de tu CDN y age: si age nunca sube de cero en recargas sucesivas, no estás cacheando nada.

En una frase

Nuestra web no era lenta por el código, por las imágenes ni por el framework: era lenta porque se servía como algo que no tenía por qué ser, y las únicas dos cabeceras que lo decían no las miraba nadie. Es el tipo de fallo que no aparece en ninguna captura de pantalla y que solo se ve midiendo.

Lanza los curl de este artículo contra tu web. Si te salen MISS donde deberían salir HIT, ya tienes la mitad del diagnóstico hecho. Y si prefieres que lo miremos nosotros con el detalle con el que lo hemos mirado aquí, cuéntanos qué tienes y te decimos qué arreglaríamos, en qué orden y qué esperar de cada cosa.

Fuentes

¿Te resulta útil este contenido? Añadinos como fuente preferida en Google.

Compartir artículo

Sobre el autor

Foto de Federico Noya

Fullstack Web Developer & Founder

Fundador de NVDigital. Desarrollador fullstack con foco en WordPress headless, Next.js y soluciones Web3.

Newsletter · 1 email al mes

No te pierdaslo siguiente que publiquemos.

Artículos sobre Next.js, IA aplicada y desarrollo de producto. Sin spam, cero rellenos — un email cuando hay algo que merece tu tiempo.

Del blog

Artículos relacionados

Continúa explorando contenido sobre el mismo tema