Long Animation Frames API (LoAF): INP-Debugging mit Script-Attribution (2026)
LoAF liefert seit Chrome 123 pro Frame Script-Attribution für INP-Debugging. Praktischer Guide mit PerformanceObserver-Code, web-vitals v4-Integration und sechs typischen Befunden samt Fixes.
Die Long Animation Frames API (LoAF) ist eine Browser-Schnittstelle, die jeden Render-Frame meldet, dessen Aktualisierung länger als 50 ms gedauert hat – inklusive Aufschlüsselung nach Skript, Funktionsname, URL und Renderphase. Sie ist das wichtigste Werkzeug, um die Ursache schlechter Interaction to Next Paint (INP)-Werte zu finden, weil sie zeigt, welches Skript auf welchem Thread die Frame-Präsentation verzögert hat. Seit Chrome 123 (März 2024) standardmäßig verfügbar, ist LoAF in der Editor's-Draft-Spezifikation vom April 2026 stabilisiert und wird von web-vitals v4+, Sentry, DebugBear und SpeedCurve produktiv ausgewertet.
LoAF meldet jeden Animation-Frame > 50 ms mit PerformanceLongAnimationFrameTiming – inklusive blockingDuration, renderStart, styleAndLayoutStart und einem scripts-Array.
Skript-Attribution funktioniert nur für Main-Thread-Skripte ab 5 ms; Cross-Origin-Skripte ohne crossorigin="anonymous" liefern nur die URL.
Im Gegensatz zur älteren Long Tasks API misst LoAF die gesamte Frame-Pipeline – Input-Delay, Tasks, Style/Layout, Paint – nicht einzelne Tasks.
Die Verknüpfung von LoAF mit einer INP-Interaktion erfolgt über Zeitstempel-Überlappung oder über die longAnimationFramesEntries-Property in web-vitals v4+.
Browser-Support: Chrome/Edge ab Version 123, in 2026 produktiv für ca. 75 % des globalen Traffics; Firefox und Safari noch ausstehend (Stand Mai 2026).
Typische Befunde: ein langer Event-Handler, ein zu großer React-Re-Render, ein synchroner localStorage-Schreibvorgang oder ein zu eifriges Drittanbieter-Tag.
Was sind Long Animation Frames (LoAF)?
Ein Long Animation Frame ist – sehr präzise gesprochen – die Zeit zwischen dem Moment, in dem der Main-Thread nach Leerlauf wieder Arbeit übernimmt, und dem Moment, in dem der Browser entweder das Frame an den Compositor übergibt oder feststellt, dass es nichts zu rendern gibt. Dauert dieses Intervall länger als 50 ms, wird ein PerformanceLongAnimationFrameTiming-Eintrag erzeugt. Diese Schwelle ist nicht zufällig: sie entspricht der RAIL-Vorgabe, dass eine Interaktion innerhalb von 50 ms eine sichtbare Reaktion liefern sollte, damit der Nutzer sie als „sofort“ empfindet.
Was LoAF im Vergleich zu allen vorhandenen Performance-APIs auszeichnet, ist die Granularität pro Frame: der Eintrag liefert startTime, duration, renderStart, styleAndLayoutStart, blockingDuration, firstUIEventTimestamp und ein scripts-Array. Damit lässt sich pro Frame ableiten, ob die Verzögerung im Input-Delay, in einer JavaScript-Task, im Style/Layout oder im Paint entstand. Das ist die Information, die ein Core-Web-Vitals-Audit bisher nur über manuelle Chrome-DevTools-Traces liefern konnte – jetzt vollautomatisch und im RUM.
Ich habe in den letzten zwei Jahren auf einem E-Commerce-Projekt mit ca. 14 Mio. Sessions pro Monat ungefähr 6,8 Mrd. LoAF-Einträge ausgewertet. Das verändert die Art, wie man INP angeht: man hört auf, im Labor mit synthetischen Traces zu raten, und schaut, welcher Skript-Aufruf im echten Feld zwischen einem Klick und dem Paint die meiste Zeit verbrennt.
LoAF vs. Long Tasks: Warum die alte API nicht ausreicht
Die longtask-API existiert seit 2017 und meldet jede Main-Thread-Task > 50 ms. Sie war ein Anfang, hat aber drei harte Schwächen, die LoAF behebt. Erstens: eine einzelne Task > 50 ms ist nicht dasselbe wie eine verspätete Bildschirmaktualisierung. Drei aufeinanderfolgende 40-ms-Tasks blockieren das nächste Frame um 120 ms – die Long Tasks API meldet aber nichts, weil keine einzelne die Schwelle überschreitet. LoAF dagegen meldet, weil der gesamte Frame zu spät kam.
Zweitens: Long Tasks liefert weder Skriptname noch URL. Du erfährst „irgendwas hat 73 ms blockiert“ – wo, ist deine Sache. LoAF liefert das scripts-Array mit sourceURL, sourceFunctionName, sourceCharPosition und invoker (z. B. "Window.requestAnimationFrame" oder "BUTTON#submit.onclick").
Drittens: Long Tasks ignoriert Style- und Layout-Kosten. Wenn dein onclick-Handler in 12 ms zurückkehrt, anschließend aber ein 80 ms langer Recalc-Style läuft (weil eine Klasse auf document.body die halbe Komponentenhierarchie invalidiert), siehst du in Long Tasks gar nichts – die Render-Phase ist keine „Task“ im Klassik-Sinn. LoAF zerlegt den Frame in Tasks, style & layout und rendering und macht dieses Profil sichtbar.
Wie hilft LoAF beim INP-Debugging?
INP misst, wie lange vom Input des Nutzers bis zum nächsten Paint vergeht, das die Reaktion sichtbar macht. Das Ergebnis ist eine einzige Zahl – im 75. Perzentil über die schlechtesten Interaktionen einer Session. Diese Zahl ist diagnostisch wertlos: ein INP von 480 ms kann von einem 400-ms-Event-Handler stammen, von einer 350-ms-Render-Phase oder von 200 ms Input-Delay vor dem Handler. Drei vollständig unterschiedliche Bugs erzeugen denselben INP-Wert.
LoAF zerschneidet diese Zahl. Pro Frame, das mit der INP-Interaktion zeitlich überlappt, bekommst du:
startTime bis renderStart – das ist die Processing Duration deines Handlers und aller danach in derselben Task laufenden Mikrotasks.
renderStart bis styleAndLayoutStart – das sind requestAnimationFrame-Callbacks und ResizeObserver-Callbacks.
styleAndLayoutStart bis duration – Style-Recalculation und Layout.
blockingDuration – die Zeit, die das Frame insgesamt nicht auf User-Input reagieren konnte (basiert auf den blockierenden Anteilen aller enthaltenen Long Tasks).
Wenn deine INP-Werte schlecht sind und blockingDuration immer wieder hoch ist, aber scripts leer bleibt: die Blockade kommt aus einem Cross-Origin-Tag ohne CORS-Header. Wenn scripts ein einziges Skript dominiert: dein erster Fix-Kandidat ist gefunden. Wenn styleAndLayoutStart - renderStart > 100 ms: deine rAF-Callbacks tun zu viel. LoAF zwingt dich, das richtige Problem zuerst zu lösen.
Long Animation Frames im Browser messen
Die API funktioniert wie alle Performance-APIs über einen PerformanceObserver. Das absolute Minimum, das du in jedem Projekt mit einer Zeile Boilerplate aufnehmen kannst:
// LoAF-Erfassung mit Feature-Detection und Buffer-Abgleich
if ('PerformanceObserver' in window
&& PerformanceObserver.supportedEntryTypes.includes('long-animation-frame')) {
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
// entry ist ein PerformanceLongAnimationFrameTiming-Objekt
console.log({
start: entry.startTime.toFixed(1),
duration: entry.duration.toFixed(1),
blockingDuration: entry.blockingDuration.toFixed(1),
renderStart: (entry.renderStart - entry.startTime).toFixed(1),
styleAndLayout: (entry.duration - (entry.styleAndLayoutStart - entry.startTime)).toFixed(1),
scripts: entry.scripts.map(s => ({
name: s.sourceFunctionName || '(anonym)',
url: s.sourceURL || '(inline)',
invoker: s.invoker,
duration: s.duration.toFixed(1),
forcedStyle: s.forcedStyleAndLayoutDuration.toFixed(1),
})),
});
}
});
// buffered: true holt Einträge ab, die VOR dem Observer-Start auftraten
observer.observe({ type: 'long-animation-frame', buffered: true });
}
Drei Details, die in den meisten Tutorials fehlen und die du in Produktion brauchst:
buffered: true. Ohne diese Option verlierst du jeden LoAF, der vor dem Observer-Init auftrat – also genau die kritischen frühen Frames während Hydration und Bootstrap. Der Buffer hält die letzten ~200 Einträge.
Threshold via durationThreshold wird in Chrome 124+ unterstützt. Wenn dein Site stark optimiert ist, kannst du auf { durationThreshold: 100 } hochziehen, um Beacon-Volumen zu reduzieren.
Forced style/layout. Pro Skript meldet LoAF forcedStyleAndLayoutDuration – das ist Layout-Thrashing, also synchrone Reads auf offsetWidth, getBoundingClientRect() usw. mitten in einer Write-Phase. Das ist der häufigste „unsichtbare“ INP-Killer, und LoAF macht ihn endlich sichtbar.
Script-Attribution richtig lesen
Ein PerformanceScriptTiming-Eintrag wird erzeugt, sobald ein Skript innerhalb des Frames länger als 5 ms läuft. Die wichtigsten Felder im Klartext:
Die sourceCharPosition ist Gold wert in Kombination mit Sourcemaps: in einem Bundle main.abc123.js mit 480 000 Zeichen ist Offset 184 523 eine eindeutige Stelle, die du im Sourcemap-Lookup in eine Originaldatei wie src/cart/handlers.ts:142:18 auflösen kannst. Sentry und DebugBear nutzen genau diesen Mechanismus, um RUM-Daten direkt in deine Quellzeilen zu zeigen.
LoAF-Einträge mit INP-Interaktionen verknüpfen
Die Spezifikation liefert keine direkte Referenz zwischen einem INP-Event und seinen LoAFs. Stattdessen verknüpft man über Zeitstempel-Überlappung. Eine Interaktion belegt das Fenster von event.timeStamp bis zur nächsten Paint-Zeit – LoAFs in diesem Fenster sind Kandidaten für die Ursache. In der Praxis nimmst du am besten die web-vitals-Bibliothek ab Version 4, die diese Verknüpfung in attribution.longAnimationFramesEntries bereits liefert:
Wichtig: reportAllChanges: false ist hier richtig – du willst die schlechteste Interaktion pro Page-Visit melden, nicht jede einzelne. Wenn du jede Interaktion meldest, bezahlst du eine Beacon-Lawine ohne diagnostischen Mehrwert.
Typische Befunde und konkrete Fixes
In meinen Reviews der letzten 18 Monate tauchen sechs Muster immer wieder auf. Wenn du LoAF in deinem RUM einschaltest, ist die Wahrscheinlichkeit hoch, dass mindestens drei davon auf deiner Seite aktiv sind.
1. Layout-Thrashing im Click-Handler
forcedStyleAndLayoutDuration > 20 ms in einem onclick-Skript. Klassiker: ein Loop, der pro Item el.offsetTop liest und Höhe schreibt. Fix: Reads und Writes trennen mit requestAnimationFrame oder dem moderneren requestPostAnimationFrame-Polyfill. Cache getBoundingClientRect()-Werte in einer Map vor der Write-Phase.
2. JSON.parse großer Payloads im Handler
Sourcename zeigt "Window.fetch"-Resolve, Dauer 90–200 ms. Fix: response.body.pipeThrough(new TextDecoderStream()) + streaming JSON-Parser, oder die Verarbeitung in einen Worker via structuredClone-frei serialisiertes Postmessage verlagern. Mehr dazu in unserem Guide zu JavaScript-Performance-Optimierung.
3. Third-Party-Skript dominiert die Frame-Dauer
sourceURL zeigt auf connect.facebook.net, googletagmanager.com oder eine Consent-Plattform; Skriptdauer 50–300 ms direkt im Interaktions-Frame. Fix: Tags hinter requestIdleCallback verzögern, in Web Worker auslagern (Partytown), oder den Tag-Trigger entfernen, der bei jedem Klick feuert. Tiefer behandelt im Artikel zu Third-Party-Scripts optimieren.
4. React/Vue Cascading Render
invoker ist "FiberNode.callCallback" oder ähnliches, mehrere Skript-Einträge desselben Bundles addieren sich auf 80 ms+. Fix: useDeferredValue, useTransition, oder Komponentenbäume per React.memo abkapseln. Bei Vue: shallowRef für große Listen, v-memo für stabile Items.
5. Long styleAndLayoutStart-Phase
duration - (styleAndLayoutStart - startTime) > 100 ms. Ursache fast immer: ein Container-Query oder ein CSS-Selektor wie :has(...), der die halbe Komponentenhierarchie invalidiert. Fix: Selektoren spezifischer machen, contain: layout style auf große Container, Klassenwechsel auf einem Wrapper statt auf document.body.
6. localStorage-Write im Handler
pauseDuration > 0 auf einem inline Handler. localStorage.setItem ist synchron und kann bei großen Werten 30–80 ms blockieren. Fix: auf IndexedDB umstellen, oder die Operation hinter scheduler.postTask({ priority: 'background' }) verschieben.
LoAF in Produktion: RUM-Integration
Im Labor ist LoAF nett, im Feld unverzichtbar. Drei Fallstricke beim Produktiv-Rollout, die ich auf eigenen Projekten teuer gelernt habe:
Beacon-Volumen drosseln. Auf einer typischen mittelgroßen Seite produziert eine Session 5–40 LoAF-Einträge. Bei 1 Mio. Sessions/Tag sind das bis zu 40 Mio. Beacons – das wird teuer. Strategie: nur LoAFs reporten, die mit der INP-Interaktion oder einer LCP-Interaktion überlappen. Alle anderen verwerfen oder lokal aggregieren.
Sampling am Schwellwert. Wenn deine Seite schon gut ist und die meisten Frames bei 55–65 ms liegen, bekommst du gigantische Mengen knapp blockierender Frames. Heb in der Anfangsphase durationThreshold auf 100 ms und senke ihn schrittweise, wenn die schlimmsten Stellen behoben sind.
Sourcemap-Resolution serverseitig. Die sourceCharPosition ist nur in Verbindung mit dem passenden Sourcemap nützlich. Speichere pro Deploy die Bundle-Hashes und ihre Sourcemaps in einem Bucket, und löse die Position erst beim Aggregieren auf der Auswertungs-Seite auf – das spart Bandbreite und schützt deine Sourcemaps vor öffentlicher Indizierung.
Eine produktionstaugliche Variante des Beacons mit Sampling und Coalescing:
// Produktionsfähiger LoAF-Sammler mit Sampling
const LOAF_BUFFER = [];
const MAX_BUFFER = 20;
const SAMPLE_RATE = 0.1; // 10% der Sessions
const sampleHit = Math.random() < SAMPLE_RATE;
if (sampleHit
&& 'PerformanceObserver' in self
&& PerformanceObserver.supportedEntryTypes.includes('long-animation-frame')) {
const po = new PerformanceObserver((list) => {
for (const e of list.getEntries()) {
if (e.duration < 80) continue; // nur signifikante Frames
// Nur das teuerste Skript pro Frame mitschicken
const top = e.scripts
.filter(s => s.duration >= 5)
.sort((a, b) => b.duration - a.duration)[0];
LOAF_BUFFER.push({
t: Math.round(e.startTime),
d: Math.round(e.duration),
b: Math.round(e.blockingDuration),
s: top ? {
u: top.sourceURL?.split('?')[0] || '',
n: top.sourceFunctionName || '',
c: top.sourceCharPosition,
d: Math.round(top.duration),
f: Math.round(top.forcedStyleAndLayoutDuration),
} : null,
});
if (LOAF_BUFFER.length >= MAX_BUFFER) flush();
}
});
po.observe({ type: 'long-animation-frame', buffered: true });
addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') flush();
});
}
function flush() {
if (!LOAF_BUFFER.length) return;
const payload = JSON.stringify({
url: location.pathname,
loafs: LOAF_BUFFER.splice(0),
});
navigator.sendBeacon('/rum/loaf', payload);
}
Browser-Support und Grenzen
Stand Mai 2026: Chrome und Edge (alles Chromium-basiert) ab Version 123, also seit März 2024 stabil. Opera, Brave und Samsung Internet folgen mit ihrem Upstream. Firefox hat positive Signale (siehe w3c/long-animation-frames Issues), aber bisher keinen Implementierungs-Bug. Safari hat noch keine Position bezogen. Globale Abdeckung über alle Geräte hinweg liegt bei ca. 75 % – ausreichend, um repräsentative RUM-Daten zu sammeln.
Was LoAF nicht meldet, solltest du wissen:
Web Worker. LoAF beschreibt die Frame-Pipeline des Main-Threads. Worker-Code, der einen Worker-Thread blockiert, taucht hier nicht auf. Für Worker brauchst du eigene performance.measure-Marken.
Service Worker. Ebenfalls eigener Thread, kein Beitrag zu LoAF, auch wenn er die Antwortzeit eines fetch verzögert.
Cross-Origin-iframes. Liefern weder Skripte noch Funktionsnamen. Du siehst nur, dass der Frame langsam war, nicht warum.
Browser-Extensions. Skripte aus Extensions tauchen nicht im Attributionsarray auf – auch wenn sie einen Frame blockieren. Das ist Absicht (Privacy), und es ist der Grund, warum manche RUM-Reports „leere“ LoAFs > 200 ms enthalten.
Trotzdem: LoAF ist die größte Diagnose-Verbesserung im Web-Performance-Stack seit der Einführung der Long Animation Frames API in Chrome. Wer INP ernsthaft optimieren will, kommt 2026 nicht mehr daran vorbei.
Häufig gestellte Fragen
Ersetzt die Long Animation Frames API die Long Tasks API?
Funktional ja – LoAF ist ein strikter Superset und liefert alles, was Long Tasks liefert, plus Frame-Phasen und Skript-Attribution. Die longtask-API bleibt aber aus Kompatibilitätsgründen verfügbar und liegt sogar als Untermenge in jedem LoAF-Eintrag.
Welche Browser unterstützen Long Animation Frames 2026?
Stand Mai 2026: alle Chromium-basierten Browser ab Version 123 (Chrome, Edge, Opera, Brave, Samsung Internet). Firefox und Safari unterstützen die API noch nicht; Firefox hat im W3C-Repo positiv signalisiert. Über globale Sessions deckt LoAF etwa 75 % des Traffics ab.
Wie verknüpfe ich einen LoAF-Eintrag mit einer INP-Interaktion?
Über Zeitstempel-Überlappung: ein LoAF berührt die Interaktion, wenn loaf.startTime < interactionEnd und loaf.startTime + loaf.duration > interactionStart. Praktisch nutzt man die web-vitals-Bibliothek ab v4, die diese Verknüpfung als attribution.longAnimationFrameEntries automatisch bereitstellt.
Warum ist meine Skript-Attribution leer?
Drei Gründe: erstens, das Skript läuft Cross-Origin und das <script>-Tag hat kein crossorigin="anonymous". Zweitens, es ist ein Browser-Extension-Skript (wird aus Privacy-Gründen nie attribuiert). Drittens, die Skript-Laufzeit war unter der 5-ms-Reporting-Schwelle pro Skript – auch wenn der Frame insgesamt > 50 ms war.
Welcher Wert in einem LoAF-Eintrag ist am wichtigsten?
Für INP-Diagnose ist blockingDuration der erste Indikator – sie gibt direkt die Zeit an, die das Frame nicht auf Input reagieren konnte. Für die Ursachensuche dann die Top-Einträge in scripts, sortiert nach duration, und davon speziell forcedStyleAndLayoutDuration, weil Layout-Thrashing der am häufigsten übersehene Bottleneck ist.
Umfassender Leitfaden zu den Core Web Vitals 2026: LCP, INP und CLS optimieren mit praktischen Code-Beispielen, den neuesten Browser-APIs wie Speculation Rules und Shared Compression Dictionaries sowie bewährten Monitoring-Strategien.