wandres.dev
LA RED · Latencia, ancho de banda y el RTT

Medir la red desde el navegador

Cómo obtener el desglose de fases de cualquier recurso con Resource Timing, qué cabeceras hacen falta, y cómo detectar un cuello de botella de red con datos y no con intuición.

⏱ 16 min

Todo lo que hemos descrito de DNS, TCP, TLS y transferencia es observable desde JavaScript, recurso a recurso, en producción y con usuarios reales. La API de tiempos de recursos es probablemente la herramienta de diagnóstico más infrautilizada de la plataforma: está en todos los navegadores, no requiere permisos y responde con precisión a la pregunta de dónde se está yendo el tiempo.

🎯 Al terminar esta lección sabrás
  • Extraer el desglose por fases de cualquier recurso con la API de tiempos de recursos.
  • Configurar las cabeceras que hacen visibles los tiempos de recursos de otro origen.
  • Detectar los tres patrones de cuello de botella de red a partir de los datos.
  • Contar orígenes y conexiones en producción para dimensionar el problema.

El objeto de tiempos de recurso

Cada recurso que carga la página genera una entrada de tipo resource con una veintena de marcas de tiempo. Estas son las que importan y qué intervalo define cada par:

Intervalo Marcas Qué mide
Redirección redirectStart a redirectEnd Tiempo perdido en redirecciones
Caché y worker fetchStart a domainLookupStart Consulta a la caché y arranque del service worker
DNS domainLookupStart a domainLookupEnd Resolución del nombre
Conexión connectStart a connectEnd TCP y, si hay, TLS
TLS secureConnectionStart a connectEnd Solo la parte segura
Petición requestStart a responseStart Envío y espera del primer byte
Respuesta responseStart a responseEnd Transferencia del cuerpo

Un detalle que confunde: el tramo de TLS está contenido dentro del de conexión. connectEnd marca el final de todo el establecimiento, TCP y TLS incluidos. Por eso no se suman, se resta desde secureConnectionStart.

Y un indicador clave: si connectStart y connectEnd coinciden, la conexión se reutilizó y no hubo establecimiento. Es la forma directa de saber cuántas conexiones nuevas está abriendo tu página.

function desglose(entrada) {
  const t = entrada;
  const conexionNueva = t.connectEnd > t.connectStart;

  return {
    url: t.name,
    tipo: t.initiatorType,
    protocolo: t.nextHopProtocol,       // 'h2', 'h3', 'http/1.1'
    conexionNueva,
    redireccion: Math.round(t.redirectEnd - t.redirectStart),
    dns:         Math.round(t.domainLookupEnd - t.domainLookupStart),
    conexion:    Math.round(t.connectEnd - t.connectStart),
    tls:         t.secureConnectionStart > 0
                   ? Math.round(t.connectEnd - t.secureConnectionStart)
                   : 0,
    espera:      Math.round(t.responseStart - t.requestStart),
    descarga:    Math.round(t.responseEnd - t.responseStart),
    total:       Math.round(t.duration),
    bytes:       t.encodedBodySize,
    desdeCache:  t.transferSize === 0 && t.decodedBodySize > 0,
  };
}

const recursos = performance.getEntriesByType('resource').map(desglose);
console.table(recursos);

Tres campos merecen comentario aparte.

nextHopProtocol te dice qué versión de HTTP se usó realmente. Es la forma más fiable de comprobar si tu CDN está sirviendo por HTTP/3 o si algo lo está degradando.

transferSize vale cero cuando la respuesta vino de la caché sin tocar la red. Combinado con decodedBodySize mayor que cero, es el detector de acierto de caché. Si además transferSize es pequeño pero no cero y decodedBodySize es grande, tienes una revalidación con respuesta 304.

initiatorType indica qué provocó la petición: link, script, img, css, fetch, xmlhttprequest. Es útil para agrupar y para detectar recursos descubiertos desde dentro de una hoja de estilos, que son los que llegan tarde.

Las cabeceras que hacen falta

Para recursos de otro origen, casi todas las marcas anteriores vienen a cero por motivos de seguridad. Solo verás startTime, responseEnd, duration y poco más.

La solución es que el servidor del recurso envíe la cabecera de permiso de tiempos:

Timing-Allow-Origin: https://tu-sitio.example

O, si aceptas exponerlo a cualquiera:

Timing-Allow-Origin: *

Merece la pena activarla en todos los orígenes que controles: tu CDN, tu dominio de imágenes, tu almacenamiento de objetos. Sin ella, los recursos que más sospechas tienes de que van lentos son precisamente los que no puedes desglosar.

Para los orígenes de terceros que no controlas, tendrás que conformarte con la duración total. Aun así, comparar la duración total de un recurso de tercero con la de uno propio de tamaño similar ya te dice bastante.

💡
Amplía el buffer o perderás entradas

El buffer de entradas de recursos tiene un tamaño por defecto limitado, del orden de un par de cientos de entradas, y cuando se llena las siguientes se descartan en silencio. En una página con muchos recursos, tu recuento saldrá corto sin ningún aviso. Amplíalo pronto con performance.setResourceTimingBufferSize(500) o escucha el evento resourcetimingbufferfull para vaciar y seguir acumulando.

Los tres patrones de cuello de botella

Con los datos anteriores, el diagnóstico se reduce a reconocer tres formas.

Patrón 1: muchas conexiones nuevas. Cuenta cuántos recursos tienen connexionNueva a verdadero y cuántos orígenes distintos hay. Si son más de tres o cuatro orígenes, tu problema es de establecimiento y la palanca es consolidar.

const origenes = new Set(recursos.map((r) => new URL(r.url).origin));
const nuevas = recursos.filter((r) => r.conexionNueva).length;
console.log({ origenes: origenes.size, conexionesNuevas: nuevas });

Patrón 2: espera alta, descarga baja. Si en muchos recursos el tramo espera es grande y el de descarga pequeño, el tiempo se va esperando respuestas, no transfiriendo. Eso es latencia y proceso de servidor. La palanca es acercar el servidor, cachear en el borde y reducir el número de peticiones en cadena.

Patrón 3: descarga alta. Si el tramo de descarga domina, ahí sí tienes un problema de bytes y de caudal. La palanca es comprimir, cambiar de formato y servir el tamaño correcto.

El diagnóstico rápido cabe en una línea:

const totales = recursos.reduce((a, r) => ({
  espera: a.espera + r.espera,
  descarga: a.descarga + r.descarga,
  establecimiento: a.establecimiento + r.dns + r.conexion,
}), { espera: 0, descarga: 0, establecimiento: 0 });

console.table(totales);

Si establecimiento es comparable a descarga, tu arquitectura de orígenes es el problema. Es más común de lo que parece.

La cadena crítica

El dato que ninguna suma revela es la cadena de dependencias: qué recurso no pudo empezar hasta que otro terminó. Se aproxima ordenando por instante de inicio y buscando huecos.

const orden = performance.getEntriesByType('resource')
  .sort((a, b) => a.startTime - b.startTime)
  .map((r) => ({
    url: r.name.split('/').pop().slice(0, 40),
    inicio: Math.round(r.startTime),
    fin: Math.round(r.responseEnd),
    tipo: r.initiatorType,
  }));

console.table(orden);

Lo que buscas es un recurso cuyo inicio sea aproximadamente igual al fin de otro anterior. Eso es una dependencia, y una cadena de tres o cuatro de esas es lo que arruina un LCP. Los eslabones típicos: el CSS que revela la fuente, el script que revela la imagen, el módulo que importa otro módulo.

Un recurso con initiatorType igual a css es sospechoso por definición: significa que se descubrió al parsear la hoja de estilos, lo cual implica que el CSS tuvo que llegar antes. Si ese recurso es crítico, debería estar referenciado desde el HTML para que el escáner de precarga lo vea antes.

El campo que resuelve más discusiones sobre CDN en menos tiempo es nextHopProtocol combinado con el desglose de conexión

Cuando alguien afirma que la CDN está mal configurada, o que la migración a HTTP/3 no ha servido de nada, hay una comprobación de treinta segundos que zanja la discusión con datos de usuarios reales. Recoge, para tus recursos principales, el par formado por nextHopProtocol y si la conexión fue nueva, y agrégalo por país. Los resultados que te vas a encontrar son casi siempre uno de estos tres, y ninguno es el que la gente espera. Puede salir que una parte de tus usuarios recibe http/1.1 porque hay un proxy corporativo o un antivirus que intercepta TLS y degrada el protocolo, cosa invisible en cualquier prueba de laboratorio. Puede salir que el protocolo es h3 pero el tiempo de establecimiento sigue siendo alto en una región concreta, lo cual apunta a que allí no hay punto de presencia y la ganancia del protocolo se la come la distancia. O puede salir que casi todas las conexiones son nuevas porque el tiempo de espera de conexión inactiva de tu servidor es de cinco segundos y tus usuarios pasan más tiempo leyendo. Los tres son problemas reales, ninguno aparece en Lighthouse, y los tres se detectan con dos campos que ya tienes disponibles.