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.
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.
- 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.
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.
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.