Hvis din INP-score er dårlig, men du ikke aner præcist hvilken kode der gør siden træg — så er Long Animation Frames API (LoAF) den manglende brik. Hvor den gamle Long Tasks API kun fortalte dig at noget var langsomt, fortæller LoAF dig hvad, hvor og hvorfor. Helt ned til specifikke scripts, funktionsnavne og forced layouts.
Ærligt talt? Det her værktøj har ændret den måde, jeg debugger frontend-performance på. Jeg har brugt timer (måske dage, hvis jeg skal være helt ærlig) på at gætte mig frem til, hvad der gjorde en knap langsom. Med LoAF tager det fem minutter.
I denne guide gennemgår vi, hvordan LoAF fungerer i 2026, hvordan du implementerer det i produktion, og hvordan du bruger script-attribution til at fikse INP-problemer hurtigere end nogensinde før.
Hvad er Long Animation Frames (LoAF)?
En long animation frame er en renderingsopdatering, hvor browseren bruger mere end 50 ms på at producere det næste frame. Når det sker, virker UI'et trægt: knapper svarer langsomt, scroll hakker, og animationer ser kluntede ud.
LoAF API'et — leveret som PerformanceLongAnimationFrameTiming-entries via PerformanceObserver — har været stabilt i Chromium-baserede browsere siden Chrome 123 og er nu en helt central del af RUM-værktøjerne i 2026.
LoAF vs. Long Tasks API: Hvad er forskellen?
- Long Tasks API: Kun et tidsstempel og en varighed. Ingen indsigt i hvilket script der kørte.
- LoAF API: Fuld attribution — script-URL, funktionsnavn, hvilken event-handler der kaldte den, hvor lang tid der blev brugt på forced layout, rendering og presentation.
Med andre ord: Long Tasks fortæller dig, at noget brænder. LoAF fortæller dig, hvilken pande maden ligger i, og hvor høj temperaturen er.
Hvorfor LoAF er afgørende for INP i 2026
INP (Interaction to Next Paint) blev en officiel Core Web Vital i marts 2024 og er stadig en rangeringsfaktor i 2026. INP måler den samlede forsinkelse fra brugerinteraktion til næste paint og består af tre faser:
- Input delay — tid før event handler starter
- Processing duration — tid hvor handler kører
- Presentation delay — tid før næste frame præsenteres
Problemet med INP alene er, at metricen kun fortæller dig, at det er langsomt. Den siger ikke, hvad du skal gøre ved det. LoAF udfylder det diagnostiske hul ved at vise præcis, hvilke scripts der blokerer hovedtråden under interaktionen. Og web-vitals-biblioteket fra v4 og frem inkluderer endda alle krydsende LoAF-entries direkte i INP-attribution-objektet via longAnimationFramesEntries.
Browser-understøttelse i maj 2026
- Chrome / Edge: Fuld understøttelse fra version 123+ (cirka 95% af brugerne globalt)
- Firefox: Positiv signal, men ikke implementeret endnu
- Safari: Endnu ingen offentlig roadmap
Selvom kun Chromium understøtter LoAF lige nu, dækker det altså mere end nok til, at de fleste danske websites kan få meningsfuld RUM-data ud af det. Brug feature detection, før du forsøger at observere:
if (
typeof PerformanceObserver !== "undefined" &&
PerformanceObserver.supportedEntryTypes &&
PerformanceObserver.supportedEntryTypes.includes("long-animation-frame")
) {
// Sikker at bruge LoAF API her
}
Sådan opfanger du Long Animation Frames
Det grundlæggende mønster bruger PerformanceObserver med entry-typen "long-animation-frame". Sæt en høj tærskel i starten, så du ikke drukner i data:
const REPORTING_THRESHOLD_MS = 150;
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.duration < REPORTING_THRESHOLD_MS) continue;
console.log("LoAF varighed:", entry.duration);
console.log("Blocking duration:", entry.blockingDuration);
console.log("Render start:", entry.renderStart);
console.log("Style/layout start:", entry.styleAndLayoutStart);
for (const script of entry.scripts) {
console.log({
url: script.sourceURL,
funktion: script.sourceFunctionName,
invoker: script.invoker,
invokerType: script.invokerType,
executionStart: script.executionStart,
forcedReflow: script.forcedStyleAndLayoutDuration,
});
}
}
});
observer.observe({ type: "long-animation-frame", buffered: true });
Bemærk buffered: true. Det er let at glemme, men det sikrer, at du modtager LoAF-entries der allerede er sket, før din observer blev registreret. Helt afgørende, hvis dit analytics-script loader sent (og det gør det jo som regel).
De vigtigste felter i en LoAF-entry
| Felt | Beskrivelse |
|---|---|
duration | Total varighed af framet (over 50 ms) |
blockingDuration | Tid hvor hovedtråden var blokeret af lange tasks |
renderStart | Hvornår rendering-fasen begyndte |
styleAndLayoutStart | Hvornår style og layout begyndte |
firstUIEventTimestamp | Hvornår det første UI-event ankom |
scripts | Array af PerformanceScriptTiming-objekter |
Script-attribution: Find synderen
Det er scripts-arrayet, der gør LoAF revolutionerende. Hver script-entry indeholder:
sourceURL— URL til scriptet (kan mangle hvis scriptet er CORS-blokeret)sourceFunctionName— funktionens navn, hvis tilgængeligtsourceCharPosition— tegn-position i kildekodeninvoker— hvad der kaldte scriptet (fxBUTTON#submit.onclick)invokerType— type:"event-listener","user-callback","resolve-promise"osv.executionStart— hvornår scriptet rent faktisk begyndte at køredesiredExecutionStart— hvornår det burde være begyndt (forskellen er input delay!)forcedStyleAndLayoutDuration— tid brugt på synkron forced reflow
Vigtigt om CORS og script-URL'er
Hvis sourceURL mangler, er scriptet sandsynligvis krydsoprindelse uden korrekte CORS-headers. Tilføj crossorigin="anonymous" til dine <script>-tags og sørg for, at serveren returnerer Access-Control-Allow-Origin:
<script src="https://cdn.example.com/analytics.js" crossorigin="anonymous"></script>
Praktisk eksempel: Find tredjepartsscripts der ødelægger INP
Et af de absolut mest almindelige scenarier er, at INP er dårlig på grund af tredjepartsscripts — analytics, ads, chatwidgets, you name it. Her er et komplet eksempel, der grupperer LoAF-data efter script-domæne:
const domainTotals = new Map();
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.duration < 100) continue;
for (const script of entry.scripts) {
if (!script.sourceURL) continue;
try {
const domain = new URL(script.sourceURL).hostname;
const current = domainTotals.get(domain) || 0;
domainTotals.set(domain, current + script.duration);
} catch (e) {
// Ignorer ugyldige URL'er
}
}
}
// Sortér og vis de værste syndere
const sorted = [...domainTotals.entries()].sort((a, b) => b[1] - a[1]);
console.table(sorted.slice(0, 10));
}).observe({ type: "long-animation-frame", buffered: true });
Kør det her i en session i 30 sekunder, og du har en nådesløst præcis liste over, hvilke domæner der koster dig INP-millisekunder. Første gang jeg gjorde det på vores produktionssite, var jeg lidt flov: vores egen analytics-leverandør lå i top tre.
Integration med web-vitals biblioteket
Fra web-vitals v4 (april 2024) og fremefter inkluderer INP-attribution automatisk LoAF-entries. Det betyder, at du kan korrelere hver dårlig INP-måling med præcis hvilke scripts der kørte i det øjeblik:
import { onINP } from "web-vitals/attribution";
onINP((metric) => {
const attribution = metric.attribution;
const loafEntries = attribution.longAnimationFrameEntries || [];
// Find det script med længst varighed under interaktionen
let worstScript = null;
let worstDuration = 0;
for (const loaf of loafEntries) {
for (const script of loaf.scripts) {
if (script.duration > worstDuration) {
worstDuration = script.duration;
worstScript = script;
}
}
}
// Send til analytics
navigator.sendBeacon("/rum", JSON.stringify({
inp: metric.value,
rating: metric.rating,
target: attribution.interactionTarget,
eventType: attribution.interactionType,
worstScriptUrl: worstScript?.sourceURL,
worstScriptFn: worstScript?.sourceFunctionName,
worstScriptDuration: worstDuration,
}));
});
Med dette setup kan du sortere RUM-data efter worstScriptUrl og se, hvilke scripts der konsekvent forårsager dårlig INP for rigtige brugere — ikke bare for dig selv på din MacBook med 5G-forbindelse.
Datamængde: Sådan undgår du at drukne din analytics
LoAF-entries kan være store, og en travl side kan generere hundredvis pr. session. Brug disse strategier til at holde datamængden håndterbar:
- Start med en høj tærskel (fx 200 ms). Sænk gradvist, mens du fikser de værste problemer.
- Saml kun top 5–10 LoAFs pr. session. Sortér efter
blockingDurationog kassér resten. - Send aggregeret data, ikke rå entries. Et opkald pr. side med summer er nok i de fleste tilfælde.
- Filtrér scripts under 5 ms. De er sjældent rodårsagen.
- Brug
navigator.sendBeacontil at sende data uden at blokere unload.
Almindelige LoAF-mønstre og deres løsninger
1. Stort forcedStyleAndLayoutDuration
Det indikerer layout thrashing — JavaScript der vekselvis læser og skriver layoutegenskaber. Løsningen? Batch dine DOM-læsninger og -skrivninger med requestAnimationFrame, eller brug et bibliotek som fastdom.
2. Stor forskel mellem desiredExecutionStart og executionStart
Hovedtråden var optaget, da event handleren ville køre. Brug scheduler.yield() til at bryde lange tasks op, eller flyt tunge beregninger til en Web Worker.
3. invokerType er "resolve-promise" med høj duration
En promise-callback kører for længe. Tjek for synkrone løkker eller tunge JSON-parsninger i .then()-handlers (klassisk fælde, det her).
4. Mange scripts fra samme tredjepartsdomæne
Overvej at lazy-loade scriptet (kun ved interaktion), brug partytown til at flytte det til en Web Worker, eller — det modige valg — fjern det helt.
LoAF i Chrome DevTools
I Chrome 132+ vises Long Animation Frames direkte i Performance-panelet i en dedikeret række over hovedtråden. Hver LoAF får en farve baseret på sværhedsgrad, og du kan klikke for at se script-attribution direkte i UI'et — uden en eneste linje kode.
Det er især nyttigt under udvikling. Optag en interaktion, find den røde LoAF, og se nøjagtigt hvilken funktion der trækker tiden ud. Simpelt.
Hvad LoAF ikke kan
- Krydsoprindelses-iframes: Scripts heri vises ikke i attribution.
- Web Workers og Service Workers: Ingen attribution, selv om de påvirker hovedtråden.
- Browser-extensions: Markeres anonymt for at undgå fingerprinting.
- Native browser-arbejde: Image decoding eller GC vises kun som ikke-script-tid.
Komplet RUM-implementering
Sæt det hele sammen med dette produktionsklare snippet, der kan droppes direkte i din side:
(function () {
if (!("PerformanceObserver" in window)) return;
if (!PerformanceObserver.supportedEntryTypes.includes("long-animation-frame")) return;
const REPORT_URL = "/rum/loaf";
const THRESHOLD_MS = 150;
const buffer = [];
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.duration < THRESHOLD_MS) continue;
const topScripts = entry.scripts
.filter((s) => s.duration > 5)
.sort((a, b) => b.duration - a.duration)
.slice(0, 3)
.map((s) => ({
url: s.sourceURL || "(ukendt)",
fn: s.sourceFunctionName || "(anonym)",
inv: s.invoker,
dur: Math.round(s.duration),
reflow: Math.round(s.forcedStyleAndLayoutDuration),
}));
buffer.push({
d: Math.round(entry.duration),
bd: Math.round(entry.blockingDuration),
rs: Math.round(entry.renderStart - entry.startTime),
scripts: topScripts,
});
}
}).observe({ type: "long-animation-frame", buffered: true });
// Send på pagehide (mere pålideligt end unload)
addEventListener("pagehide", () => {
if (buffer.length === 0) return;
const payload = JSON.stringify({
url: location.pathname,
loafs: buffer.slice(0, 10),
});
navigator.sendBeacon(REPORT_URL, payload);
}, { once: true });
})();
Næste skridt: Fra data til handling
Når du har LoAF-data flydende, så følg den her workflow:
- Find top 3 langsomste scripts på tværs af alle sessioner.
- Identificér mønstret: Er det forced reflow? Lange event handlers? Tredjepart?
- Anvend den rette løsning:
scheduler.yield(), batching, lazy loading eller fjernelse. - Mål igen efter en uge: Forbedrede INP'en sig? Hvis ikke, gå tilbage til trin 1.
Ofte stillede spørgsmål
Hvad er forskellen mellem Long Tasks API og Long Animation Frames API?
Long Tasks API rapporterer kun, at en task tog over 50 ms — uden detaljer. LoAF API rapporterer hele animation framet, inklusive script-attribution, forced layout-tid og presentation delay. LoAF er en erstatning, ikke et supplement.
Virker LoAF i Safari og Firefox?
Pr. maj 2026 understøttes LoAF kun i Chromium-browsere (Chrome 123+, Edge 123+). Firefox har vist positiv interesse, men har endnu ikke implementeret det. Safari har ingen offentlig roadmap. Brug altid feature detection.
Hvor lav skal min INP være?
Google anbefaler en INP på under 200 ms ved den 75. percentil for at bestå Core Web Vitals. Dårlig INP er over 500 ms. LoAF hjælper dig med at gå fra "dårlig" til "behov for forbedring" til "god" — ved at vise præcis hvilke scripts der bremser interaktioner.
Kan LoAF forårsage performance-problemer i sig selv?
Nej. LoAF er passiv observation og har minimal overhead. Den eneste reelle bekymring er datamængden i din analytics-pipeline — løs det med en høj tærskel og aggregering på klientsiden, før du sender.
Hvorfor mangler sourceURL i nogle af mine LoAF-entries?
Den mest almindelige årsag er, at scriptet er fra en anden oprindelse uden korrekte CORS-headers. Tilføj crossorigin="anonymous" til <script>-tagget, og sørg for, at serveren sender Access-Control-Allow-Origin. Hvis scriptet er injiceret af en browser-extension, vil URL'en være anonymiseret af privacy-grunde — der er desværre ikke noget, du kan gøre ved det.
Konklusion
Long Animation Frames API er det vigtigste diagnostiske værktøj for INP i 2026. Hvor INP fortæller dig, at din side føles træg, fortæller LoAF dig, hvilken kode der gør det. Implementér RUM-snippet'et ovenfor, lad det køre en uge, og du står med en klar prioriteret liste over præcis, hvad du skal fikse for at booste din responsivitets-score — og din SEO med det.