Long Animation Frames API: INP debuggen in productie
Een productie-playbook voor het vinden van het script dat je interactie verstoorde

Jarenlang werd de vraag "wat veroorzaakt mijn INP" vaak beantwoord met een lege blik. De Long Tasks API vertelde je dat de main thread langer dan 50ms werd geblokkeerd. Het vertelde je niet welk script. Het vertelde je niet welke regel. Alles wat we hadden was een getal in ms.
De Long Animation Frames API lost dat op. Het is beschikbaar in Chromium-browsers sinds Chrome 123 en geeft je de volledige anatomie van een frame: totale duur, blokkeerduur, het moment dat de renderer begon, het moment dat style en lay-out begonnen, en een array van elk script dat langer dan 5ms in het frame draaide. Elke scriptvermelding bevat de bron-URL, de functienaam, de tekenpositie in het bestand en het type callback dat het aanriep.
Deze pagina is onderdeel van onze Interaction to Next Paint (INP) serie. Het onderstaande LoAF-playbook is wat ik uitvoer na het diagnosticeren van de metriek. Als je nieuw bent met INP, begin dan met de driefasengidsen voor input delay, processing time en presentation delay, en de bredere find-and-fix INP workflow.
Waarom Long Tasks niet genoeg was
Een long task vuurt af wanneer een enkele taak op de main thread langer dan 50ms draait. Dat is nuttig voor het vangen van de voor de hand liggende blokkades. Het is nutteloos voor INP omdat de meeste trage interacties niet één long task zijn. Het is een reeks middelgrote taken plus een lange render.
Een interactie kan falen voor INP zonder ooit een long task te triggeren. Een event handler van 40ms gevolgd door een style recalc van 70ms resulteert in 110ms presentation delay. De Long Tasks API rapporteert niets. De gebruiker voelt de vertraging.
De andere tekortkoming van Long Tasks is attributie. Je krijgt een container type, een container name en een name veld dat je vertelt of de long task "self", "same-origin-ancestor", "same-origin-descendant", "unknown" of een van de paar cross-origin varianten was. Je krijgt niet het script dat draaide. Dus zelfs als je een long task opvangt, moet je nog steeds DevTools openen en de interactie opnieuw opnemen om de oorzaak te vinden. Dat werkt in een lab. Het werkt niet voor INP bij echte gebruikers die je niet kunt reproduceren.
LoAF vervangt beide beperkingen. Het legt het volledige frame vast, niet een enkele taak. En het vertelt je precies welk script er draaide, via URL en functienaam.
De zeven velden die ertoe doen
Een LoAF entry heeft meer velden dan je nodig hebt. Deze zeven zijn degene die ik als eerste lees bij het debuggen van een interactie.
duration is de totale lengte van het frame in milliseconden. Een LoAF entry wordt pas aangemaakt als dit 50ms overschrijdt. Dit getal alleen vertelt je of het frame een probleem is, maar niet waarom.
blockingDuration is nuttiger. Het telt de delen van het frame op die de long task drempel overschreden, waarbij van elk 50ms wordt afgetrokken. Een frame met duration: 320 en blockingDuration: 0 is voornamelijk renderwerk. Een frame met duration: 320 en blockingDuration: 270 is een lang script. De eerste heeft een render-fix nodig. De tweede heeft een codewijziging nodig.
renderStart is de timestamp waarop de browser begon met renderwerk voor het frame. Alles tussen startTime en renderStart is scriptuitvoering. Alles daarna is style, lay-out en paint. Dit is de meest bruikbare grens voor INP-debugging omdat het je vertelt of het knelpunt JavaScript of rendering is.
styleAndLayoutStart is de timestamp waarop rendering overging van het uitvoeren van animation frame callbacks naar daadwerkelijke style-herberekening en lay-out. De ruimte tussen renderStart en styleAndLayoutStart zijn je requestAnimationFrame callbacks. De ruimte tussen styleAndLayoutStart en het einde van het frame is browser-side style- en lay-outwerk.
firstUIEventTimestamp is wanneer er een gebruikersinput arriveerde tijdens het frame. Als deze waarde niet-nul is, bevat het frame een interactie. Dit is hoe web-vitals.js LoAF entries correleert met INP-metingen. Geen firstUIEventTimestamp betekent dat het frame traag is, maar dat er geen gebruiker op wachtte.
scripts is de array die je leest voor attributie. Elke entry is een script dat ten minste 5ms draaide. Het bevat sourceURL, sourceFunctionName, invoker, invokerType, duration en forcedStyleAndLayoutDuration. De laatste is cruciaal: het vertelt je of het script synchrone lay-out triggerde, wat de hoofdoorzaak is van de meeste processing time-knelpunten die ik in audits zie.
Het invokerType-veld op elk script vertelt je hoe het werd aangeroepen. De volledige lijst is event-listener (clicks, scrolls, keydowns), user-callback (setTimeout, setInterval, requestAnimationFrame), resolve-promise en reject-promise (then/catch handlers), classic-script en module-script. Voor INP lees je als eerste event-listener voor processing time, en user-callback, classic-script of module-script voor input delay. Promise-handlers staan zelden bovenaan de lijst.
LoAF in productie vastleggen met web-vitals.js
Je hoeft geen LoAF observer helemaal opnieuw te bouwen. De web-vitals.js attributie build doet dit voor je en correleert LoAF entries aan daadwerkelijke INP-metingen. Dit is de versie die ik op klantsites installeer.
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));
}); Deze payload is klein genoeg om zonder sampling te verzenden. Drie velden doen het meeste diagnostische werk: longestScriptInvoker, longestScriptSource en longestScriptSubpart. De eerste twee vertellen je wat er draaide. De derde vertelt je in welke INP-fase het belandde.
Als je volledige attributie wilt in plaats van alleen het langste script, stuur dan ook a.longAnimationFrameEntries mee. Wees je ervan bewust dat één INP-meting meerdere LoAF's kan bevatten en een zware LoAF kan tien of meer script entries bevatten. Het verzenden van de volledige array bij elke paginaweergave is kostbaar. Meestal verstuur ik de volledige array alleen via beacon wanneer metric.rating !== 'good'.
Een belangrijk detail. De longAnimationFrameEntries-array is leeg wanneer LoAF en de INP kandidaat-interactie niet overlappen, of wanneer de browser LoAF helemaal niet ondersteunt. Doe altijd aan feature-detectie voordat je een lege array als een schoon signaal behandelt.
const loafSupported = PerformanceObserver.supportedEntryTypes?.includes('long-animation-frame'); De privacyoverweging. sourceURL-waarden van scripts kunnen queryparameters en volledige paden bevatten. Voor klantwerk strip ik alles behalve de origin en pathname voordat ik het naar RUM stuur. Gehashte bundelbestandsnamen zijn handig voor debugging binnen een deploy, maar nutteloos voor het groeperen over deploys heen, dus ik strip ook het hash-segment.
Attributiepatronen van 925.000 URL's
CoreDash legt LoAF-attributie vast voor elke INP-meting op de sites die we monitoren. Binnen onze dataset van 925.000 URL's komen steeds weer dezelfde handvol scriptcategorieën naar voren als de dominante oorzaak van blokkerende LoAF's.
Op e-commerce sites zijn de meest blokkerende origins consistent. Tag managers die synchrone aangepaste HTML-tags draaien, staan op de eerste plaats. Consent management platforms die late-binding scriptinjectie uitvoeren, staan op de tweede plaats. Advertentie- en bid-script orkestratie is derde. De vierde zijn product recommendation widgets die zware client-side hydratatie doen. De vijfde zijn session replay-scripts die elke interactie instrumenteren.
Wat klanten verrast, is het aandeel van forcedStyleAndLayoutDuration. Bij een typisch slecht INP-frame is 30 tot 60% van de duur van het langste script te wijten aan het script dat synchrone lay-out triggert. Het script is niet traag omdat de JavaScript zwaar is. Het is traag omdat de JavaScript lay-out leest (offsetHeight, getBoundingClientRect) binnen een lus die ook naar de DOM schrijft. Layout thrashing. Hetzelfde probleem dat ontwikkelaars al vijftien jaar schrijven, nog steeds de grootste afzonderlijke bijdrager aan processing time op de sites die ik audit.
Binnen onze dataset zijn event-listener invokers goed voor ongeveer de helft van de langste scripts die INP kruisen. user-callback (setTimeout, setInterval, requestAnimationFrame) is goed voor ongeveer een kwart. classic-script top-level uitvoering is verantwoordelijk voor het grootste deel van wat overblijft. Promise-resolvers zijn zelden het langste script, wat een veelvoorkomende aanname tegenspreekt dat "async-code de oorzaak is" van INP.
De vijf LoAF-patronen die ik het meest zie
Na genoeg audits beginnen de LoAF entries er bekend uit te zien. De meeste falende interacties komen overeen met een van vijf patronen. Elk heeft een andere fix.
De third-party event listener
Het langste script heeft invokerType: "event-listener" en een sourceURL van een third-party origin. De scriptduur is het grootste deel van het frame en forcedStyleAndLayoutDuration is laag. Dit is een vendor tag die naar je kliks luistert en te veel werk verricht in de handler. De fix is zelden "verwijder de tag". De fix is om het werk dat de tag doet uit te stellen. Specifiek voor dataLayer pushes is er een patroon dat je 20 tot 100ms oplevert zonder analytics te breken, wat ik heb behandeld in Scheduling dataLayer Events to optimize the INP.
Layout thrash binnen je eigen handler
Het langste script heeft invokerType: "event-listener", de sourceURL is je eigen bundel en forcedStyleAndLayoutDuration is meer dan 30% van de scriptduur. Het script leest de lay-out in een lus. De fix is om reads voor writes te batchen, of het werk te verplaatsen naar requestAnimationFrame en gecachte waarden te gebruiken.
Ik zie dit het vaakst bij productfilterpagina's op e-commerce sites. De filter-handler updatet het zichtbare productaantal, leest vervolgens de nieuwe hoogte van de resultatenlijst om een sticky filterknop te herpositioneren, updatet daarna een CSS custom property op basis van die hoogte en leest dan de hoogte opnieuw. Vier geforceerde lay-outs in één handler. LoAF plaatst die 80 tot 150ms aan forcedStyleAndLayoutDuration op een enkele script entry en de fix wordt overduidelijk. Lees één keer, schrijf één keer, exit.
Hydration cascade bij de eerste interactie
De LoAF vuurt af terwijl loadState nog steeds "loading" of "dom-interactive" is. firstUIEventTimestamp bevindt zich in een frame waar scripts framework hydratatie-callbacks bevat. De gebruiker klikte voordat de pagina was gehydrateerd. De fix is niet om hydratatie sneller te maken, hoewel dat helpt. De fix is om de interactieve elementen te laten werken zonder hydratatie: native links en form submits, native disclosure widgets, native dialogs. Progressive enhancement.
Presentation delay door een zware DOM
Deze ziet er anders uit in de data. blockingDuration is laag. De scripts-array is kort. Maar de ruimte tussen styleAndLayoutStart en het einde van het frame is meer dan 100ms. Geen enkele JavaScript-fix zal helpen. De renderer verslikt zich in style-herberekening over duizenden nodes. Gebruik content-visibility: auto op offscreen secties, contain: layout style op zware componenten en verklein de omvang van de style scope die per interactie opnieuw moet berekenen.
De rAF-storm
Meerdere LoAF's rijgen zich aaneen, elk met een user-callback script dat wordt aangeroepen door Window.requestAnimationFrame. De pagina draait een JavaScript-animatie die concurreert met de gebruiker. JavaScript-gedreven scrollgedrag is de meest voorkomende versie. Ik heb gezien dat sites 200ms aan presentation delay toevoegen aan elke klik op elke pagina vanwege één smooth-scroll polyfill waarvan niemand zich herinnert dat deze is uitgerold. Ik heb de scroll-case behandeld in Improve the INP by ditching JavaScript scrolling. Dezelfde logica geldt voor elke JavaScript-animatie: schakel over op CSS of de Web Animations API en de LoAF's verdwijnen.
Hoe een LoAF-payload er in de praktijk uitziet
Hier is een echte LoAF entry uit een audit van een afrekenpagina. Ik heb de URL's geanonimiseerd, maar de structuur en timings zijn intact.
{
"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
}
]
} Lees het als volgt. Het frame begon op 5902ms na page load. Op 5913ms klikte de gebruiker op de checkout submit-knop. De click handler startte op 5914ms en draaide 248ms. Van die 248ms was 142ms geforceerde synchrone lay-out. De renderer kon pas op 6170ms beginnen, meer dan een kwart seconde na de klik. INP voor deze interactie is ongeveer 270ms, in de "needs improvement" zone.
De fix is niet "maak handleCheckoutSubmit sneller". De fix is "stop met het forceren van lay-out binnen handleCheckoutSubmit". 142ms van de 248ms is niet de JavaScript. Het is het lezen van lay-outwaarden die door de JavaScript writes ongeldig zijn gemaakt. In de echte case waar dit vandaan kwam, verlaagde het één keer cachen van de gelezen waarden voor de write-lus de scriptduur van 248ms naar 110ms. INP op de afrekenpagina ging van p75 270ms naar onder de 200ms. De pagina was geslaagd.
Dat is het soort fix dat niet naar voren komt in een lab-tool. Lighthouse kan je niets vertellen over forcedStyleAndLayoutDuration omdat Lighthouse geen interactie met de pagina aangaat. LoAF in de praktijk is de enige manier om het te zien.
Een AI-agent aansturen met LoAF-data
De reden dat LoAF van belang is voor AI-ondersteunde debugging, is dat het een agent iets concreets geeft om over na te denken. Een agent zonder veldgegevens kan gissen naar de INP-oorzaken vanuit je code. Een agent met LoAF entries van echte gebruikerssessies kan het script, de functie en de fase benoemen. De diagnose is geen gok meer.
De workflow die ik met Claude Code gebruik: haal de slechtste INP-pagina op uit CoreDash via de MCP-server, voeg de LoAF entries toe voor de langzaamste interacties van die URL en vraag de agent om te identificeren met welk van de vijf bovenstaande patronen elke entry overeenkomt. De agent classificeert elke LoAF, wijst naar de functie in de codebase en stelt een fix voor. Ik beoordeel de fix. Ik pas hem toe. CoreDash meet of p75 INP verbetert.
Dit werkt omdat LoAF-data gestructureerd en klein is. Een enkele LoAF JSON-payload past in een fractie van het contextvenster van een agent. Een handvol representatieve LoAF's voor een enkele pagina is genoeg om een fix aan te sturen. Ik schreef over het bredere patroon in Fix INP with an AI agent: the metric lab tools cannot measure en AI Agent Core Web Vitals: why field data changes everything.
Cross-browser realiteit
LoAF is alleen voor Chromium. Safari implementeert het niet. Firefox implementeert het niet. Aangezien INP zelf nu beschikbaar is in Safari 26.2 en Firefox 144, heb je een metingskloof. Je INP RUM-data is cross-browser. Je attributiedata is Chrome en Edge.
Ik zie klanten aarzelen om LoAF te adopteren om deze reden. Ze willen wachten tot Safari het uitbrengt. Dat is de verkeerde keuze. Chrome is waar de meeste performance-bugs voor het eerst worden gereproduceerd en de meeste performance-fixes voor het eerst worden gevalideerd. De vorm van het probleem (lange event handler, layout thrash, hydration cascade) verandert niet tussen browsers. Alleen de diagnostische lens verschilt. Fixes die je uitbrengt op basis van Chromium LoAF-data verbeteren INP ook voor Safari- en Firefox-gebruikers.
Als je vandaag enige vorm van attributie in Safari wilt, is EventTiming wat je hebt. Het vertelt je welk event traag was, maar niet welk script. Dat is genoeg om te weten waar je moet zoeken, maar niet wat je moet fixen. Voor Safari-attributie is lab profiling in de Safari Web Inspector de praktische fallback.
Wat LoAF je nog steeds niet kan vertellen
Drie blinde vlekken zijn de moeite waard om te kennen, omdat ze je op een gegeven moment zullen overvallen.
De eerste is cross-origin iframes. LoAF kent alleen attributie toe aan scripts die draaien op de main thread van vensters die de origin van het document delen. Een blokkerend script binnen een third-party iframe verschijnt als ondoorzichtige attributie: een lang frame en een lege scripts-array. De fix is hetzelfde als voor elk prestatieprobleem met third-party iframes: stel het laden van de iframe uit, geef het expliciete afmetingen en gebruik loading="lazy" als het below the fold is.
Workers zijn de tweede blinde vlek. Web workers verschijnen niet in LoAF entries omdat LoAF een main-thread API is. Als je INP slecht is omdat een worker zware berichten terugstuurt naar de main thread, toont LoAF je de message handler op de main thread, maar niet het worker-werk dat het triggerde. Je moet handmatig kruisverwijzingen maken met behulp van performance marks in de worker.
En tot slot, GPU-compositor vertraging. Het einde van een LoAF entry is wanneer de renderer klaar is met zijn werk op de main thread. Het daadwerkelijke moment van pixels op het scherm is later, in de compositor en de GPU. Op low-end Android-apparaten kan de compositorvertraging 30 tot 100ms toevoegen die LoAF niet ziet. Het presentationTime-veld in LoAF entries (momenteel achter de experimentele PaintTimingMixin vlag) is bedoeld om dit aan te pakken, maar is nog niet stabiel in alle Chrome-versies.
Geen van deze zijn fataal. Ze betekenen simpelweg dat LoAF niet het hele verhaal is. Voor 80% van de INP-problemen op echte klantsites is het genoeg.
Waar te beginnen
Als je nog nooit naar LoAF-data hebt gekeken, begin dan in de praktijk, niet in het lab. Installeer de web-vitals.js attributie build, stuur een beacon met het langste script per INP-meting en bekijk de top tien longestScriptSource-waarden over een week aan verkeer. Wat er ook bovenaan staat, is je eerste fix, ongeacht wat je Lighthouse-rapport je vertelt.
Als je de lab-weergave wilt, toont het Chrome DevTools Performance-paneel LoAF niet standaard, maar de data is beschikbaar via PerformanceObserver. De custom track benadering met behulp van performance.measure met het devtools detailveld brengt LoAF entries in het paneel naar boven. Het webperf-snippets project heeft een console-snippet dat een overzichtstabel van LoAF entries met interacties afdrukt, wat ik doorgaans als eerste uitvoer bij het debuggen van een specifieke pagina.
Verken de rest van de INP-serie
Het LoAF-playbook past binnen de bredere workflow. Om dieper in te gaan op elke fase of stap:
- Vind en fix INP-problemen: de diagnostische flow van RUM-data naar de trage interactie.
- Input delay: scripts die draaien voordat de event handler kan starten.
- Processing time: de event handler zelf.
- Presentation delay: het renderwerk nadat de handler terugkeert.
- INP-hub: de volledige gids over de metriek.
Bronnen: Chrome for Developers, Long Animation Frames API, MDN, Long animation frame timing, W3C Long Animation Frames specificatie, GoogleChrome/web-vitals bibliotheek.
Performance zakt in zodra niemand meer kijkt.
Ik tuig de monitoring op, de performance budgets en het proces. Dat is het verschil tussen een fix en een oplossing.
Even sparren?Jouw vragen over de Long Animation Frames API beantwoord
Browser-ondersteuning en wat LoAF vervangt
Wordt de Long Animation Frames API in alle browsers ondersteund?
Nee. LoAF is alleen voor Chromium. Chrome, Edge, Opera en Brave hebben het. Safari en Firefox niet. Doe altijd aan feature-detectie met PerformanceObserver.supportedEntryTypes?.includes('long-animation-frame') voordat je erop vertrouwt.
Vervangt LoAF de Long Tasks API?
Voor INP-debugging wel. Long Tasks geeft je main-thread blokkeerduur zonder scriptattributie. LoAF geeft je hetzelfde blokkerende signaal plus het daadwerkelijke script dat draaide. Er is geen reden om Long Tasks te gebruiken voor nieuw INP-werk. Total Blocking Time wordt mogelijk uiteindelijk geherdefinieerd in termen van LoAF blockingDuration.
LoAF-data lezen
Hoe correleer ik een LoAF entry aan een specifieke INP-meting?
De web-vitals.js attributie build doet dit voor je via attribution.longAnimationFrameEntries. Het vindt de LoAF entries waarvan het timingvenster overlapt met de INP kandidaat-interactie. Als je je eigen observer bouwt, vergelijk dan entry.startTime en entry.startTime + entry.duration met de interactietijd en voeg elke LoAF toe waarvan het venster kruist.
Wat betekent forcedStyleAndLayoutDuration eigenlijk?
Het is de tijd die het script heeft besteed aan het triggeren van synchrone lay-out. Wanneer je JavaScript offsetHeight, getBoundingClientRect of een andere eigenschap leest die een up-to-date lay-out vereist na een DOM-mutatie, moet de browser het lay-outwerk synchroon binnen het script uitvoeren. Dat werk wordt toegeschreven aan het script. Een hoge forcedStyleAndLayoutDuration is bijna altijd layout thrashing.
Waarom is de scripts-array leeg voor sommige LoAF entries?
Drie redenen. Ten eerste bevat de entry alleen scripts die minstens 5ms hebben gedraaid. Een frame vol met korte scripts onder die drempel toont geen attributie. Ten tweede krijgen scripts die in cross-origin iframes, web workers, service workers of browser-extensies draaien geen attributie, omdat LoAF alleen de same-origin main thread ziet. Ten derde heeft het frame mogelijk helemaal geen scriptwerk, alleen style en lay-out, wat een presentation delay frame is.
Verder dan INP
Kan LoAF helpen bij LCP, niet alleen INP?
Ja, indirect. Een LoAF die draait voordat het LCP-element rendert, is een lang script dat LCP render vertraagt. Filter LoAF's op degene waarvan de eindtijd voor de LCP-timestamp ligt, en het langste script daarin is de grootste bijdrager aan LCP render delay. Dezelfde attributiedata, toegepast op een andere metriek.