Long Animation Frames API: Depurar INP en producción
Una guía de producción para encontrar el script que rompió tu interacción

Durante años, la respuesta a "qué está causando mi INP" solía ser una mirada en blanco. La Long Tasks API te decía que el hilo principal (main thread) estaba bloqueado durante más de 50ms. No te decía qué script. No te decía qué línea. Todo lo que teníamos era un número en ms.
La Long Animation Frames API soluciona eso. Se incluye en los navegadores Chromium desde Chrome 123 y te ofrece la anatomía completa de un frame: duración total, duración de bloqueo, el momento en que comenzó el renderizador, el momento en que comenzaron los estilos y el layout, y un array de cada script que se ejecutó en el frame durante más de 5ms. Cada entrada de script incluye la URL de origen, el nombre de la función, la posición del carácter en el archivo y el tipo de callback que lo invocó.
Esta página es parte de nuestra serie sobre Interaction to Next Paint (INP). La guía de LoAF a continuación es lo que ejecuto después de diagnosticar la métrica. Si eres nuevo en INP, comienza con las guías de tres fases para retraso de entrada (input delay), tiempo de procesamiento (processing time) y retraso de presentación (presentation delay), y el flujo de trabajo más amplio para encontrar y solucionar INP.
Por qué Long Tasks no era suficiente
Una tarea larga (long task) se activa cuando una sola tarea en el hilo principal se ejecuta durante más de 50ms. Eso es útil para detectar los bloqueadores obvios. Es inútil para INP porque la mayoría de las interacciones lentas no son una sola tarea larga. Son una secuencia de tareas medianas más un renderizado largo.
Una interacción puede fallar en INP sin llegar a activar nunca una tarea larga. Un event handler de 40ms seguido de un recálculo de estilos de 70ms suma 110ms de retraso en la presentación. La Long Tasks API no reporta nada. El usuario siente el lag.
El otro fallo de Long Tasks es la atribución. Obtienes un tipo de contenedor, un nombre de contenedor y un campo de nombre que te indica si la tarea larga fue "self", "same-origin-ancestor", "same-origin-descendant", "unknown", o una de algunas variantes de origen cruzado. No obtienes el script que se ejecutó. Así que, incluso cuando detectas una tarea larga, todavía tienes que abrir DevTools y volver a grabar la interacción para encontrar la causa. Eso funciona en un laboratorio. No funciona para INP en usuarios reales que no puedes reproducir.
LoAF reemplaza ambas limitaciones. Captura el frame entero, no una sola tarea. Y te dice exactamente qué script se ejecutó, por URL y nombre de función.
Los siete campos que importan
Una entrada de LoAF tiene más campos de los que necesitas. Estos siete son los que leo primero cuando depuro una interacción.
duration es la longitud total del frame en milisegundos. Una entrada de LoAF solo se crea cuando esto supera los 50ms. Este número por sí solo te dice si el frame es un problema, pero no por qué.
blockingDuration es más útil. Suma las partes del frame que superaron el umbral de tarea larga, restando 50ms de cada una. Un frame con duration: 320 y blockingDuration: 0 es en su mayoría trabajo de renderizado. Un frame con duration: 320 y blockingDuration: 270 es un script largo. El primero necesita una corrección de renderizado. El segundo necesita un cambio de código.
renderStart es la marca de tiempo (timestamp) en la que el navegador comenzó a realizar el trabajo de renderizado para el frame. Todo entre startTime y renderStart es ejecución de script. Todo lo posterior es estilo, layout y pintura (paint). Este es el límite más útil por sí solo para depurar INP porque te dice si el cuello de botella es JavaScript o el renderizado.
styleAndLayoutStart es la marca de tiempo en la que el renderizado pasó de ejecutar callbacks de animation frame al recálculo real de estilos y layout. La brecha entre renderStart y styleAndLayoutStart son tus callbacks de requestAnimationFrame. La brecha entre styleAndLayoutStart y el final del frame es el trabajo de estilos y layout del lado del navegador.
firstUIEventTimestamp es cuando llegó una entrada del usuario durante el frame. Si este valor es distinto de cero, el frame contiene una interacción. Así es como web-vitals.js correlaciona las entradas de LoAF con las mediciones de INP. La ausencia de firstUIEventTimestamp significa que el frame es lento pero ningún usuario lo estaba esperando.
scripts es el array que lees para la atribución. Cada entrada es un script que se ejecutó durante al menos 5ms. Incluye sourceURL, sourceFunctionName, invoker, invokerType, duration, y forcedStyleAndLayoutDuration. El último es crítico: te dice si el script activó un layout síncrono, que es la causa principal de la mayoría de los cuellos de botella de tiempo de procesamiento que veo en las auditorías.
El campo invokerType en cada script te dice cómo fue llamado. La lista completa es event-listener (clics, scrolls, keydowns), user-callback (setTimeout, setInterval, requestAnimationFrame), resolve-promise y reject-promise (manejadores then/catch), classic-script, y module-script. Para INP, los que lees primero son event-listener para el tiempo de procesamiento, y user-callback, classic-script, o module-script para el retraso de entrada. Los manejadores de promesas rara vez encabezan la lista.
Asigna estos campos a las tres fases de INP y la imagen quedará clara. El retraso de entrada aparece como scripts que se ejecutan antes de firstUIEventTimestamp. El tiempo de procesamiento son scripts de event-listener que se ejecutan después del evento de la interfaz de usuario (UI event). El retraso de presentación es todo lo que hay entre renderStart y el final del frame.
Capturando LoAF en producción con web-vitals.js
No necesitas crear un observador de LoAF desde cero. La compilación de atribución de web-vitals.js lo hace por ti y correlaciona las entradas de LoAF con las mediciones reales de INP. Esta es la versión que instalo en los sitios de mis clientes.
import { onINP } from 'web-vitals/attribution';
onINP((metric) => {
const a = metric.attribution;
const payload = {
value: metric.value,
rating: metric.rating,
url: location.pathname,
loadState: a.loadState,
interactionTarget: a.interactionTarget,
interactionType: a.interactionType,
inputDelay: a.inputDelay,
processingDuration: a.processingDuration,
presentationDelay: a.presentationDelay,
longestScriptDuration: a.longestScript?.entry?.duration,
longestScriptInvoker: a.longestScript?.entry?.invoker,
longestScriptSource: a.longestScript?.entry?.sourceURL,
longestScriptSubpart: a.longestScript?.subpart,
longestScriptIntersecting: a.longestScript?.intersectingDuration,
};
navigator.sendBeacon('/rum', JSON.stringify(payload));
}); Ese payload es lo suficientemente pequeño como para enviarlo sin muestreo. Tres campos hacen la mayor parte del trabajo de diagnóstico: longestScriptInvoker, longestScriptSource, y longestScriptSubpart. Los primeros dos te dicen qué se ejecutó. El tercero te dice en qué fase de INP aterrizó.
Si deseas la atribución completa en lugar de solo el script más largo, envía también a.longAnimationFrameEntries. Ten en cuenta que una medición de INP puede incluir múltiples LoAFs y un LoAF pesado puede incluir diez o más entradas de script. Enviar el array completo en cada visita a la página es costoso. Por lo general, solo envío el array completo a través de un beacon cuando metric.rating !== 'good'.
Un detalle importante. El array longAnimationFrameEntries está vacío cuando LoAF y la interacción candidata de INP no se superponen, o cuando el navegador no soporta LoAF en absoluto. Siempre detecta la característica (feature-detect) antes de tratar un array vacío como una señal limpia.
const loafSupported = PerformanceObserver.supportedEntryTypes?.includes('long-animation-frame'); La consideración de privacidad. Los valores de sourceURL de los scripts pueden contener parámetros de consulta (query parameters) y rutas completas. Para el trabajo con clientes, elimino todo excepto el origen (origin) y la ruta (pathname) antes de enviarlo al RUM. Los nombres de archivo de los bundles con hash son útiles para la depuración dentro de un despliegue, pero inútiles para agrupar entre despliegues, así que también elimino el segmento del hash.
Patrones de atribución de 925,000 URLs
CoreDash captura la atribución de LoAF para cada medición de INP en los sitios que monitoreamos. A lo largo de nuestro conjunto de datos de 925,000 URLs, el mismo puñado de categorías de scripts aparece como la causa dominante de los LoAFs bloqueantes una y otra vez.
En los sitios de comercio electrónico (e-commerce), los principales orígenes bloqueantes son consistentes. Los gestores de etiquetas (tag managers) que ejecutan etiquetas HTML personalizadas de forma síncrona son los primeros. Las plataformas de gestión de consentimientos que realizan inyección de scripts tardía (late-binding) son las segundas. La orquestación de scripts de anuncios y pujas (bid) es la tercera. El cuarto son los widgets de recomendación de productos que hidratan gran parte del lado del cliente (client-side). El quinto son los scripts de repetición de sesiones (session replay) que instrumentan cada interacción.
Lo que sorprende a los clientes es la proporción de forcedStyleAndLayoutDuration. En un frame típico con mal INP, del 30 al 60% de la duración del script más largo es el script activando layout síncrono. El script no es lento porque el JavaScript sea pesado. Es lento porque el JavaScript lee el layout (offsetHeight, getBoundingClientRect) dentro de un bucle que también escribe en el DOM. Layout thrashing. El mismo problema que los desarrolladores han estado escribiendo durante quince años, sigue siendo el mayor contribuyente individual al tiempo de procesamiento en los sitios que audito.
En nuestro conjunto de datos, los invocadores event-listener representan aproximadamente la mitad de los scripts más largos que se cruzan con INP. user-callback (setTimeout, setInterval, requestAnimationFrame) representa alrededor de una cuarta parte. La ejecución de nivel superior (top-level) de classic-script representa la mayor parte de lo que queda. Los resolvedores de promesas (promise resolvers) rara vez son el script más largo, lo cual contradice una suposición común de que "el código asíncrono es la causa" del INP.
Las cinco formas de LoAF que veo con más frecuencia
Después de suficientes auditorías, las entradas de LoAF comienzan a resultar familiares. La mayoría de las interacciones que fallan coinciden con una de cinco formas. Cada una tiene una solución diferente.
El event listener de terceros
El script más largo tiene invokerType: "event-listener" y un sourceURL de un origen de terceros. La duración del script ocupa la mayor parte del frame y forcedStyleAndLayoutDuration es bajo. Esta es una etiqueta de un proveedor que escucha tus clics y hace demasiado trabajo en el handler. La solución rara vez es "eliminar la etiqueta". La solución es diferir el trabajo que hace la etiqueta. Para los envíos a dataLayer (dataLayer pushes) en específico, existe un patrón que te devuelve de 20 a 100ms sin romper la analítica, el cual cubrí en Programación de eventos dataLayer para optimizar el INP.
Layout thrashing dentro de tu propio handler
El script más largo tiene invokerType: "event-listener", el sourceURL es tu propio bundle, y forcedStyleAndLayoutDuration es más del 30% de la duración del script. El script está leyendo el layout en un bucle. La solución es agrupar (batch) las lecturas antes de las escrituras, o mover el trabajo a requestAnimationFrame y utilizar valores almacenados en caché.
Veo esto con mayor frecuencia en las páginas de filtros de productos en sitios de e-commerce. El handler del filtro actualiza el recuento de productos visibles, luego lee la nueva altura de la lista de resultados para reposicionar un botón de filtro fijo (sticky), luego actualiza una propiedad personalizada de CSS basándose en esa altura, y luego vuelve a leer la altura. Cuatro layouts forzados en un solo handler. LoAF pone esos 80 a 150ms de forcedStyleAndLayoutDuration en una sola entrada de script y la solución se vuelve obvia. Leer una vez, escribir una vez, salir.
Cascada de hidratación en la primera interacción
El LoAF se activa mientras loadState es todavía "loading" o "dom-interactive". firstUIEventTimestamp se encuentra dentro de un frame donde scripts contiene callbacks de hidratación del framework. El usuario hizo clic antes de que la página estuviera hidratada. La solución no es hacer la hidratación más rápida, aunque eso ayuda. La solución es hacer que los elementos interactivos funcionen sin hidratación: enlaces y envíos de formularios nativos, widgets de revelación (disclosure) nativos, cuadros de diálogo nativos. Mejora progresiva (Progressive enhancement).
Retraso de presentación por un DOM pesado
Este se ve diferente en los datos. blockingDuration es bajo. El array de scripts es corto. Pero la brecha entre styleAndLayoutStart y el final del frame es de más de 100ms. Ninguna corrección de JavaScript ayudará. El renderizador se está asfixiando con el recálculo de estilos a través de miles de nodos. Utiliza content-visibility: auto en secciones fuera de la pantalla (offscreen), contain: layout style en componentes pesados, y reduce el tamaño del alcance de estilos (style scope) que tiene que recalcularse por interacción.
La tormenta de rAF
Múltiples LoAFs se encadenan, cada uno con un script de user-callback invocado por Window.requestAnimationFrame. La página está ejecutando una animación en JavaScript que compite con el usuario. El comportamiento de desplazamiento (scroll) impulsado por JavaScript es la versión más común. He visto sitios agregar 200ms de retraso de presentación a cada clic en cada página debido a un polyfill de smooth-scroll que nadie recuerda haber publicado. Cubrí el caso del scroll en Mejorar el INP abandonando el desplazamiento con JavaScript. La misma lógica aplica para cualquier animación en JavaScript: cambia a CSS o a la Web Animations API y los LoAFs desaparecen.
Cómo se ve un payload de LoAF en el campo (field)
Aquí hay una entrada real de LoAF de una auditoría en una página de checkout. He anonimizado las URLs pero la estructura y los tiempos están intactos.
{
"name": "long-animation-frame",
"entryType": "long-animation-frame",
"startTime": 5902,
"duration": 320,
"blockingDuration": 268,
"renderStart": 6170,
"styleAndLayoutStart": 6172,
"firstUIEventTimestamp": 5913,
"scripts": [
{
"name": "script",
"entryType": "script",
"invoker": "BUTTON#checkout-submit.onclick",
"invokerType": "event-listener",
"sourceURL": "https://cdn.example.com/app.HASH.js",
"sourceFunctionName": "handleCheckoutSubmit",
"duration": 248,
"forcedStyleAndLayoutDuration": 142,
"executionStart": 5914,
"startTime": 5914
}
]
} Léelo de esta manera. El frame comenzó a los 5902ms después de la carga de la página. A los 5913ms el usuario hizo clic en el botón de enviar (submit) del checkout. El click handler comenzó a los 5914ms y se ejecutó durante 248ms. De esos 248ms, 142ms fueron layout síncrono forzado. El renderizador no pudo comenzar hasta los 6170ms, más de un cuarto de segundo después del clic. El INP para esta interacción ronda los 270ms, en la zona de "necesita mejora" (needs improvement).
La solución no es "hacer que handleCheckoutSubmit sea más rápido". La solución es "dejar de forzar layout dentro de handleCheckoutSubmit". 142ms de 248ms no es el JavaScript. Es la lectura de valores de layout que las escrituras de JavaScript han invalidado. En el encargo real del que provino esto, almacenar en caché los valores de lectura una vez antes del bucle de escritura redujo la duración del script de 248ms a 110ms. El INP en la página de checkout pasó del p75 de 270ms a menos de 200ms. La página fue aprobada.
Ese es el tipo de solución que no aparece en ninguna herramienta de laboratorio. Lighthouse no puede hablarte de forcedStyleAndLayoutDuration porque Lighthouse no interactúa con la página. LoAF en el RUM (field) es la única manera de verlo.
Manejando un agente de IA con datos de LoAF
La razón por la que LoAF es importante para la depuración asistida por IA es que le da a un agente algo concreto sobre lo que razonar. Un agente sin datos de campo (field data) puede adivinar las causas de INP basándose en tu código. Un agente con entradas de LoAF de sesiones de usuarios reales puede nombrar el script, la función y la fase. El diagnóstico deja de ser una suposición.
El flujo de trabajo que utilizo con Claude Code: obtengo la peor página de INP de CoreDash a través del servidor MCP, adjunto las entradas de LoAF para las interacciones más lentas de esa URL, y le pido al agente que identifique con cuál de las cinco formas anteriores coincide cada entrada. El agente clasifica cada LoAF, señala la función en la base del código, y propone una solución. Yo reviso la solución. La aplico. CoreDash mide si el p75 del INP se mueve.
Esto funciona porque los datos de LoAF están estructurados y son pequeños. Un solo payload JSON de LoAF cabe en una fracción de la ventana de contexto de un agente. Un puñado de LoAFs representativos para una sola página es suficiente para impulsar una corrección. Escribí sobre el patrón más amplio en Solucionar INP con un agente de IA: la métrica que las herramientas de laboratorio no pueden medir y Core Web Vitals para Agentes de IA: por qué los datos de campo lo cambian todo.
Realidad cross-browser
LoAF es solo para Chromium. Safari no lo implementa. Firefox no lo implementa. Con el propio INP ya incluido en Safari 26.2 y Firefox 144, tienes una brecha de medición. Tus datos RUM de INP son cross-browser. Tus datos de atribución son Chrome y Edge.
Veo a clientes dudar en adoptar LoAF debido a esto. Quieren esperar hasta que Safari lo incluya. Esa es la decisión equivocada. Chrome es donde la mayoría de los bugs de rendimiento se reproducen primero y donde la mayoría de las correcciones de rendimiento se validan por primera vez. La forma del problema (event handler largo, layout thrashing, cascada de hidratación) no cambia entre navegadores. Solo difiere la lente de diagnóstico. Las soluciones que envías basadas en datos de LoAF de Chromium también mejoran el INP para los usuarios de Safari y Firefox.
Si deseas algún tipo de atribución en Safari hoy, EventTiming es lo que tienes. Te dice qué evento fue lento, pero no qué script. Eso es suficiente para saber dónde mirar, pero no qué arreglar. Para la atribución en Safari, hacer profiling en el laboratorio con el Safari Web Inspector es el fallback práctico.
Lo que LoAF aún no puede decirte
Vale la pena conocer tres puntos ciegos porque te tomarán por sorpresa en algún momento.
El primero son los iframes de origen cruzado (cross-origin). LoAF solo atribuye scripts que se ejecutan en el hilo principal de las ventanas que comparten el origen (origin) del documento. Un script bloqueante dentro de un iframe de terceros aparece como una atribución opaca: un frame largo y un array scripts vacío. La solución es la misma que para cualquier problema de rendimiento de iframes de terceros: diferir la carga del iframe, darle dimensiones explícitas, y usar loading="lazy" si se encuentra debajo de la línea de pliegue (below the fold).
Los workers son el segundo punto ciego. Los web workers no aparecen en las entradas de LoAF porque LoAF es una API del hilo principal. Si tu INP es malo porque un worker está enviando mensajes pesados de vuelta al hilo principal, LoAF te muestra el message handler en el hilo principal pero no el trabajo del worker que lo activó. Debes cruzar las referencias manualmente usando marcas de rendimiento (performance marks) dentro del worker.
Y finalmente, el retraso del compositor de la GPU. El final de una entrada de LoAF es cuando el renderizador termina su trabajo en el hilo principal. El momento real de los píxeles en pantalla es posterior, en el compositor y la GPU. En dispositivos Android de gama baja, el retraso del compositor puede añadir de 30 a 100ms que LoAF no ve. El campo presentationTime en las entradas de LoAF (actualmente bajo la bandera (flag) experimental PaintTimingMixin) está pensado para solucionar esto, pero aún no es estable a través de las versiones de Chrome.
Por dónde empezar
Si nunca antes has examinado datos de LoAF, comienza en el RUM (field), no en el laboratorio. Instala la compilación de atribución de web-vitals.js, envía a través del beacon el script más largo por cada medición de INP, y observa los diez valores principales de longestScriptSource durante una semana de tráfico. Sea lo que sea que esté en la parte superior, será tu primera corrección, independientemente de lo que te diga tu informe de Lighthouse.
Si deseas la vista de laboratorio, el panel de Rendimiento de Chrome DevTools no muestra LoAF de forma nativa, pero los datos están disponibles a través de PerformanceObserver. El enfoque de pista personalizada (custom track) que utiliza performance.measure con el campo de detalle devtools hace aflorar las entradas de LoAF dentro del panel. El proyecto webperf-snippets tiene un fragmento de consola que imprime una tabla resumen de entradas LoAF con interacciones, lo cual es lo que usualmente ejecuto primero al depurar una página en específico.
Explora el resto de la serie sobre INP
La guía de LoAF encaja dentro del flujo de trabajo más grande. Para profundizar en cada fase o paso:
- Encontrar y solucionar problemas de INP: el flujo de diagnóstico desde los datos RUM hasta la interacción lenta.
- Retraso de entrada (Input delay): scripts que se ejecutan antes de que pueda comenzar el event handler.
- Tiempo de procesamiento (Processing time): el event handler en sí.
- Retraso de presentación (Presentation delay): el trabajo de renderizado después de que el handler retorna.
- Hub de INP: la guía completa de la métrica.
Fuentes: Chrome for Developers, Long Animation Frames API, MDN, Temporización de long animation frame, Especificación de W3C Long Animation Frames, Biblioteca GoogleChrome/web-vitals.
El rendimiento se cae en cuanto dejas de mirar.
Monto el monitoring, los performance budgets y los procesos. Ahí está la diferencia entre un fix y una solución.
HablemosTus preguntas sobre la Long Animation Frames API respondidas
Soporte de navegadores y qué reemplaza LoAF
¿La Long Animation Frames API es soportada en todos los navegadores?
No. LoAF es solo para Chromium. Chrome, Edge, Opera y Brave la tienen. Safari y Firefox no. Siempre detecta la característica (feature-detect) con PerformanceObserver.supportedEntryTypes?.includes('long-animation-frame') antes de depender de ella.
¿LoAF reemplaza la Long Tasks API?
Para la depuración de INP, sí. Long Tasks te da la duración de bloqueo del hilo principal sin atribución de scripts. LoAF te da la misma señal de bloqueo más el script real que se ejecutó. No hay razón para usar Long Tasks para el trabajo nuevo de INP. El Tiempo Total de Bloqueo (Total Blocking Time) eventualmente podría ser redefinido en términos de blockingDuration de LoAF.
Leyendo datos de LoAF
¿Cómo correlaciono una entrada de LoAF con una medición de INP específica?
La compilación de atribución de web-vitals.js hace esto por ti a través de attribution.longAnimationFrameEntries. Encuentra las entradas de LoAF cuya ventana de tiempo se superpone con la interacción candidata de INP. Si estás creando tu propio observador, compara entry.startTime y entry.startTime + entry.duration contra el tiempo de la interacción, e incluye cualquier LoAF cuya ventana se intersecte.
¿Qué significa realmente forcedStyleAndLayoutDuration?
Es el tiempo que el script pasó activando layout síncrono. Cuando tu JavaScript lee offsetHeight, getBoundingClientRect, o cualquier otra propiedad que requiera un layout actualizado después de una mutación del DOM, el navegador tiene que hacer trabajo de layout síncronamente dentro del script. Ese trabajo se le atribuye de vuelta al script. Un forcedStyleAndLayoutDuration alto es casi siempre layout thrashing.
¿Por qué el array de scripts está vacío en algunas entradas de LoAF?
Tres razones. Primero, la entrada solo contiene scripts que se ejecutaron durante al menos 5ms. Un frame lleno de scripts cortos por debajo de ese umbral no mostrará atribución. Segundo, los scripts que se ejecutan dentro de iframes de origen cruzado, web workers, service workers o extensiones del navegador no se atribuyen porque LoAF solo ve el hilo principal del mismo origen. Tercero, el frame puede no tener ningún trabajo de script en absoluto, solo estilos y layout, lo cual es un frame de retraso de presentación.
Más allá de INP
¿Puede LoAF ayudar con el LCP, y no solo con INP?
Sí, indirectamente. Un LoAF que se ejecuta antes de que se renderice el elemento LCP es un script largo que retrasa el renderizado del LCP. Filtra los LoAFs a aquellos cuyo tiempo de finalización sea antes del timestamp del LCP, y el script más largo allí es el mayor contribuyente al retraso de renderizado del LCP. Los mismos datos de atribución, aplicados a una métrica diferente.