wandres.dev
TERCEROS · El código que no controlas

Medir script a script: atribución real del hilo principal

Cómo saber qué script concreto se comió cuatrocientos milisegundos, con Long Animation Frames y su desglose por origen, el experimento de bloqueo para obtener el coste marginal, y por qué la suma de las partes no da el total.

⏱ 20 min

Las estimaciones sirven para ordenar candidatos; para tomar decisiones hace falta un número medido. La pregunta que hay que saber responder es incómodamente concreta: cuántos milisegundos de hilo principal consume este script en particular, en el dispositivo de mis usuarios, en mi página. Hay dos formas de averiguarlo y son complementarias: la atribución directa, que dice qué se ejecutó, y el experimento de retirada, que dice qué pasa si no está. Las dos dan números distintos, y entender por qué es la parte interesante.

🎯 Al terminar esta lección sabrás
  • Instrumentar la atribución por script con la API de Long Animation Frames.
  • Agrupar el tiempo de hilo principal por origen de tercero en datos de campo.
  • Diseñar el experimento de bloqueo que da el coste marginal de un tercero.
  • Explicar por qué la suma de los costes individuales no coincide con el coste total.

Atribución con Long Animation Frames

La medida de tiempo de tarea larga clásica dice que hubo una tarea de 380 milisegundos, y nada más. No dice de quién. Eso la hace inútil para una conversación sobre terceros, porque la respuesta previsible es “no sabemos si es nuestro”.

La API de fotogramas de animación largos resuelve exactamente eso: entrega, junto a la duración del fotograma, un array de las ejecuciones de script que participaron, con su URL de origen, quién las invocó y cuánto duraron. Es la herramienta que convierte esta discusión en una con datos. Está disponible en Chromium, así que la instrumentación tiene que ser opcional y no dar por hecho que existe.

function vigilarTerceros(enviar) {
  if (!PerformanceObserver.supportedEntryTypes?.includes('long-animation-frame')) {
    return;   // No disponible en este motor.
  }

  const acumulado = new Map();

  new PerformanceObserver((lista) => {
    for (const fotograma of lista.getEntries()) {
      for (const guion of fotograma.scripts) {
        const url = guion.sourceURL || '(anonimo)';
        let origen;
        try {
          origen = new URL(url).origin;
        } catch {
          origen = '(en linea)';
        }
        const propio = origen === location.origin || origen === '(en linea)';

        const d = acumulado.get(origen) || { ms: 0, layoutMs: 0, veces: 0, propio };
        d.ms += guion.duration;
        d.layoutMs += guion.forcedStyleAndLayoutDuration || 0;
        d.veces += 1;
        acumulado.set(origen, d);
      }
    }
  }).observe({ type: 'long-animation-frame', buffered: true });

  // Enviar al salir, con el resto de datos de campo.
  addEventListener('visibilitychange', () => {
    if (document.visibilityState !== 'hidden') return;
    const filas = [...acumulado.entries()]
      .map(([origen, d]) => ({ origen, ...d, ms: Math.round(d.ms) }))
      .sort((a, b) => b.ms - a.ms);
    if (filas.length) enviar(filas);
  }, { once: false });
}

vigilarTerceros((filas) => {
  navigator.sendBeacon('/rum/terceros', JSON.stringify(filas));
});

Cuatro cosas de este código merecen comentario.

sourceURL viene vacío para código en línea y también, y esto importa, para scripts de otro origen que no envían Timing-Allow-Origin. Es la misma restricción de siempre: el navegador no revela información detallada de un origen que no lo autoriza. En la práctica los terceros grandes sí la envían y los pequeños a menudo no, así que tendrás un cubo de “anónimo” que conviene vigilar; si crece mucho, la atribución está incompleta y hay que decirlo al presentar los datos.

forcedStyleAndLayoutDuration separa, dentro del tiempo del script, la parte gastada en recálculos de estilo y de layout provocados de forma síncrona. Es exactamente el fenómeno de el layout síncrono forzado, y verlo atribuido a un script de tercero es un argumento muy potente: no solo consume su tiempo, sino que obliga al navegador a rehacer trabajo que ya estaba planificado.

La agregación en un Map y el envío al ocultarse la página, en lugar de un envío por fotograma, es lo que hace esto viable en producción: enviar cada fotograma largo generaría un volumen absurdo y añadiría coste propio.

Y una advertencia sobre la interpretación: esto mide fotogramas largos, no todo el trabajo. Un tercero que hace mucho trabajo repartido en fragmentos cortos no aparece aquí, aunque su suma sea grande. Es un sesgo hacia lo que más molesta al usuario, que está bien, pero hay que saberlo.

El experimento de bloqueo

La atribución dice qué se ejecutó. El experimento de retirada dice qué pasa si no está, y da un número distinto y más útil para decidir: el coste marginal.

La versión rápida, para explorar en local, es el bloqueo de peticiones de las herramientas del navegador. Se añade un patrón de URL a la lista de bloqueo, se recarga y se comparan las métricas. Sirve para descubrir candidatos y no sirve para nada más, porque una sola ejecución en tu máquina no es una medida.

La versión que produce un número defendible es un experimento controlado en campo. La estructura mínima:

// Reparto estable por visitante, decidido antes de cargar ningun tercero.
function cubetaTerceros() {
  const clave = 'exp-terceros';
  let v = sessionStorage.getItem(clave);
  if (v === null) {
    v = Math.random() < 0.5 ? 'con' : 'sin';
    sessionStorage.setItem(clave, v);
  }
  return v;
}

const grupo = cubetaTerceros();

// Marca el grupo en todos los datos de campo que envies.
window.__grupoExperimento = grupo;

if (grupo === 'con') {
  cargarChatDeSoporte();
}

Y las reglas que hacen que el resultado signifique algo:

Reparto por visitante, no por carga de página. Si el grupo se sortea en cada carga, un mismo usuario alterna entre grupos y las métricas de sesión se contaminan.

La decisión, antes de cargar nada. Si el reparto depende de un script que llega tarde, el grupo “sin” ya ha pagado parte del coste.

Medir las dos caras. Este es el punto que convierte el experimento en una herramienta de decisión y no en una munición: hay que medir el coste técnico y el beneficio que el tercero dice aportar. Si el chat de soporte cuesta 600 milisegundos de bloqueo y genera un 4 % de conversaciones que acaban en venta, la conversación es sobre dos números, no sobre uno.

Duración suficiente para cubrir un ciclo semanal. El tráfico de martes no es el de sábado.

El resultado se presenta como una diferencia de distribuciones, no de medias: el percentil 75 de LCP y de INP en cada grupo, con el número de muestras. La comparación de medias en métricas de rendimiento es engañosa por la asimetría de sus distribuciones.

Por qué la suma no da el total

Aquí está el fenómeno que sorprende y que hay que anticipar antes de que alguien lo use en tu contra en una reunión.

Mides cinco terceros de uno en uno, cada uno con su experimento de retirada. Sale que el primero cuesta 300 milisegundos, el segundo 250, el tercero 200, el cuarto 150 y el quinto 100. Suma: mil. Ahora los quitas todos a la vez y esperas ganar mil milisegundos, y ganas mil ochocientos. O quitas uno y no ganas nada.

Las dos desviaciones tienen explicación y son la misma en el fondo: el hilo principal es un recurso compartido con contención, no una suma de partes independientes.

Por qué la suma se queda corta. Cuando hay cinco scripts compitiendo, ninguno se ejecuta limpiamente: se interrumpen entre sí, se invalidan las cachés del procesador, la memoria crece y el recolector de basura entra más veces, y cada tarea larga desplaza a las siguientes en cadena. Quitar los cinco elimina no solo su tiempo sino también la contención que generaban entre ellos. En dispositivos de gama baja este efecto es grande, porque la memoria limitada amplifica el coste del recolector.

Por qué a veces quitar uno no cambia nada. Si el camino crítico está determinado por otra cosa —la imagen del elemento más grande, una fuente, tu propio paquete—, quitar un tercero que se ejecutaba en paralelo no mueve la métrica, aunque su tiempo de ejecución fuera real. El tiempo estaba, pero no estaba en el camino crítico. Esto es lo que explica el resultado más frustrante de estos experimentos: “he quitado el script y el LCP es el mismo”. No significa que fuera gratis; significa que había un cuello de botella mayor delante.

La consecuencia metodológica es concreta y ahorra muchas discusiones: mide los terceros en bloque para la decisión estratégica y de uno en uno para priorizar dentro del bloque. El número que llevas a la reunión de presupuesto es el del bloque completo, porque es el que refleja lo que ganarías de verdad. El orden en que atacas es el de las medidas individuales.

Y hay un corolario práctico: cuando el experimento en bloque da una mejora grande y las individuales dan poco cada una, la respuesta correcta no es “ninguno es culpable” sino “el problema es el número total, no ninguno en concreto”, que es exactamente el argumento que justifica un presupuesto de terceros con tope en lugar de una discusión etiqueta por etiqueta.

La medida que cambia decisiones no es la del coste del tercero, sino la del coste por unidad del beneficio que dice aportar

Un ingeniero llega a una reunión con la cifra de que la herramienta de grabación de sesiones cuesta 480 milisegundos de bloqueo en el percentil 75 de móvil. Es una medida excelente, hecha con la atribución correcta, defendible ante cualquiera. Y no va a cambiar ninguna decisión, porque la persona que aprobó esa herramienta no tiene ninguna forma de comparar 480 milisegundos con lo que ella cree que la herramienta aporta. Le estás dando una magnitud en una unidad que no forma parte de su modelo del mundo, y la respuesta racional desde su punto de vista es asentir y no hacer nada. El movimiento que sí cambia decisiones es dar el paso siguiente y expresar el coste en la unidad del beneficio que el propio tercero reclama. La grabación de sesiones existe para que el equipo de producto vea reproducciones; pregunta cuántas se ven al mes y divide: si son cuarenta y el coste son 480 milisegundos aplicados a dos millones de visitas, cada reproducción vista cuesta unas siete horas acumuladas de espera de usuarios. El contenedor de etiquetas existe para poder añadir etiquetas sin desplegar; pregunta cuántas se han añadido este año sin pasar por ingeniería y divide el coste entre ellas. El chat existe para generar conversaciones; divide entre las conversaciones del mes. Esas divisiones no son retórica: son la construcción explícita de un ratio de coste por unidad de valor, y su virtud es que se puede comparar con otras cosas. Cuando el resultado es bueno, el tercero se queda y se queda con argumentos, que también sirve. Cuando es malo, es malo de una forma que la otra persona reconoce sin necesidad de entender qué es el hilo principal. Y hay un efecto secundario todavía más valioso que el resultado: la pregunta obliga a que alguien mida el beneficio, y muy a menudo nadie lo había medido nunca. Una fracción notable de los terceros de cualquier sitio maduro se instalaron para un proyecto que terminó, o los pidió una persona que ya no está, o resuelven un problema que se resolvió de otra manera. Ninguna medida de rendimiento los va a quitar. La pregunta “¿cuántas veces se usó esto el mes pasado?” los quita en una tarde, y es la herramienta de rendimiento más eficaz de todo este nivel.

⚔️ Atribuye y experimenta
  1. Despliega el vigilante de fotogramas largos en producción durante una semana y ordena los orígenes por milisegundos acumulados.
  2. Mira qué porcentaje del tiempo cae en el cubo anónimo. Si es alto, pide Timing-Allow-Origin a esos proveedores.
  3. Elige tu tercero más caro y monta el experimento de retirada con reparto estable por visitante. Mide LCP e INP en el percentil 75 de los dos grupos.
  4. Repite el experimento quitando todos los terceros a la vez y compara con la suma de los individuales.
  5. Para cada tercero, averigua cuántas veces se usó su funcionalidad el mes pasado y calcula el coste por uso.