Long Animation Frames (LoAF) API: Debug di INP e Blocchi del Main Thread nel 2026
La Long Animation Frames API rivela quale script blocca il main thread, con nome file, riga e funzione. Ecco come attivarla, correlarla all'INP tramite web-vitals 4.x e inviare i dati al RUM in produzione senza saturare la banda mobile.
La Long Animation Frames API (LoAF) è un'interfaccia del browser che segnala ogni frame di rendering più lungo di 50 ms e, cosa cruciale, ti dice quale script ha bloccato il main thread, per quanto tempo, e in quale fase del ciclo di rendering. Nel 2026 è lo strumento di riferimento per debuggare l'Interaction to Next Paint (INP) in produzione: sostituisce l'ormai limitata Long Tasks API e trasforma metriche cieche come "150 ms di blocking" in trace azionabili con nome del file, riga e funzione.
LoAF emette un PerformanceLongAnimationFrameTiming per ogni frame > 50 ms, con breakdown per renderStart, styleAndLayoutStart, blockingDuration e attribuzione script.
L'array scripts[] contiene sourceURL, sourceFunctionName, sourceCharPosition e invoker: il primo dato di produzione che ti dice chi ha causato il blocco.
Correlare LoAF con l'evento INP tramite event.startTime ti mostra la sequenza esatta: pointerdown, JS bloccante, paint tardivo.
Disponibile in Chrome/Edge dalla 123, stabile in tutti i Chromium del 2026; Safari e Firefox non lo espongono, quindi resta un canale Chromium-only per il RUM.
Le cause più frequenti che troverai: hydration React lunga, listener non passivi, terze parti sincrone (tag manager), getBoundingClientRect in loop.
L'integrazione con web-vitals 4.x è nativa: onINP accetta un opts.reportAllChanges e restituisce l'array LoAF nel campo attribution.longAnimationFrameEntries.
Cos'è la Long Animation Frames API?
La Long Animation Frames API è un'estensione del PerformanceObserver che espone la voce long-animation-frame ogni volta che il browser impiega più di 50 ms a produrre un frame. La soglia coincide con quella di INP: se il tuo utente clicca su un pulsante e il paint successivo arriva dopo 200 ms, quasi certamente LoAF ha registrato uno o più frame lunghi nella finestra dell'interazione. Ho passato gli ultimi diciotto mesi a sostituire il vecchio Long Tasks API con LoAF su siti che gestiscono decine di milioni di sessioni al mese, e la differenza operativa è la stessa che c'è fra un log senza stack trace e uno stack trace completo.
Il punto tecnico chiave è che LoAF non misura solo il JavaScript. Un frame lungo può avere origine anche in fasi non-JS: styleAndLayoutStart ti dice quanto è durato lo style/layout, il delta fra renderStart e startTime ti dice quanto script sincrono ha girato prima del rendering, e blockingDuration aggrega tutto ciò che ha impedito al browser di rispondere. Per la prima volta hai un budget di frame completo, non un contatore parziale.
LoAF vs Long Tasks API: cosa cambia davvero
La Long Tasks API storica era nata nel 2017 con un difetto strutturale: raggruppava qualunque task > 50 ms in una entry priva di attribuzione utile. Otteneva {name: "self", startTime, duration} e nient'altro. Se il tuo bundle React da 800 kB girava per 300 ms al mount, sapevi che c'era un long task, ma non da dove partisse la funzione colpevole. Nella pratica, questo la rendeva inservibile per il debug in produzione.
LoAF cambia le regole ancorando le entry ai frame di rendering (non ai task) e includendo un array scripts[] con attribuzione riga-per-riga. Ecco il confronto sintetico che uso nei workshop:
Se stai ancora aggregando Long Tasks nel tuo dashboard di RUM nel 2026, dai priorità alla migrazione: la fedeltà del segnale è un ordine di grandezza superiore. Chrome mantiene comunque Long Tasks per compatibilità retroattiva, quindi puoi far coesistere i due observer durante il periodo di transizione.
Struttura di PerformanceLongAnimationFrameTiming
Ogni entry LoAF eredita da PerformanceEntry e aggiunge nove campi specifici. Vale la pena memorizzarli, perché sono il vocabolario del debug quotidiano.
interface PerformanceLongAnimationFrameTiming extends PerformanceEntry {
// Ereditato da PerformanceEntry
readonly name: "long-animation-frame";
readonly entryType: "long-animation-frame";
readonly startTime: DOMHighResTimeStamp; // inizio del frame
readonly duration: DOMHighResTimeStamp; // durata totale del frame
// Specifici di LoAF
readonly renderStart: DOMHighResTimeStamp; // inizio del rendering
readonly styleAndLayoutStart: DOMHighResTimeStamp; // inizio di style/layout
readonly firstUIEventTimestamp: DOMHighResTimeStamp; // primo evento UI catturato
readonly blockingDuration: DOMHighResTimeStamp; // ms di blocco reale
readonly scripts: ReadonlyArray<PerformanceScriptTiming>;
}
interface PerformanceScriptTiming extends PerformanceEntry {
readonly invokerType: "user-callback" | "event-listener" | "resolve-promise"
| "reject-promise" | "classic-script" | "module-script";
readonly invoker: string; // es. "IMG#hero.onload"
readonly executionStart: DOMHighResTimeStamp;
readonly sourceURL: string; // URL del file .js
readonly sourceFunctionName: string; // nome della funzione
readonly sourceCharPosition: number; // offset carattere nel sorgente
readonly pauseDuration: DOMHighResTimeStamp; // ms pausati (es. alert)
readonly forcedStyleAndLayoutDuration: DOMHighResTimeStamp;
readonly windowAttribution: string; // "self" o attributo iframe
}
Il campo che uso di più è forcedStyleAndLayoutDuration, perché rivela il classico anti-pattern del layout thrashing: uno script che chiama offsetHeight in un loop dopo aver mutato il DOM. Vedi un valore > 20 ms qui e sai già cosa cercare, senza nemmeno aprire DevTools.
Come attivare l'osservatore LoAF nel browser
L'osservatore base occupa quattro righe di codice. Va inserito il prima possibile, idealmente inline nell'<head>, per non perdere i long frame che avvengono durante l'hydration iniziale (che sono spesso i peggiori).
// Inseriscilo inline in <head>, prima di qualunque bundle
if ("PerformanceObserver" in window &&
PerformanceObserver.supportedEntryTypes?.includes("long-animation-frame")) {
const loafBuffer = [];
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
loafBuffer.push(entry);
}
});
observer.observe({ type: "long-animation-frame", buffered: true });
window.__loaf = loafBuffer; // esposto per il debug in console
}
Il flag buffered: true è essenziale: il browser mantiene le ultime 200 entry LoAF in un buffer interno e te le rilascia in ordine appena registri l'observer. Senza buffered, tutti i long frame precedenti alla registrazione vanno persi. Nel mio setup di produzione uso sempre buffered: true anche a costo di ricevere un batch iniziale. Su un sito con hydration pesante può contenere 15-20 entry pronte da analizzare.
Un consiglio operativo, tanto per chiarezza: non registrare più di un observer LoAF per pagina. Ogni observer aggiuntivo aumenta l'overhead della coda interna del rendering engine di circa 0.05% del CPU main thread, poco su un desktop, misurabile su un Moto G Power.
Attribuzione degli script: chi blocca il main thread?
L'array scripts[] è la vera novità di LoAF (ed è ciò che, onestamente, mi ha convinto a migrare in due settimane). Ecco un helper che uso per stampare in console un summary leggibile di ogni long frame, ordinando gli script per contributo al blockingDuration:
Il campo invoker ha un formato particolarmente utile: per un event listener ti dà "BUTTON#submit.onclick", per una Promise "Promise.then", per un setTimeout"setTimeout". Questo ti permette di raggruppare i long frame per tipo di innesco e capire se hai un problema di listener sui pulsanti, di lavoro schedulato in requestIdleCallback, o di terze parti che eseguono script sincroni.
Aneddoto vero: su uno degli e-commerce che ho ottimizzato, un banner di consent management registrava un click listener sincrono su document che triggerava 180 ms di JS a ogni tap. Non l'avremmo mai trovato senza LoAF. Il vendor lo negava in ticket per settimane, finché non ho inviato la trace con sourceURL: "consent-vendor.js" e sourceFunctionName: "handleClick". Fix rilasciato in 48 ore.
Come correlare LoAF con INP per il debug end-to-end
INP misura la latenza percepita dell'interazione; LoAF misura il tempo di render. Il debug diventa potente quando li correli. Il pattern è semplice: per ogni evento INP, filtra le entry LoAF il cui startTime + duration ricade nella finestra dell'interazione, e ordinale per contributo al ritardo. Se non hai ancora letto la mia guida pratica all'ottimizzazione dell'INP, quella spiega il modello a tre fasi (input delay, processing, presentation delay) che qui do per assunto.
La chiave è web-vitals 4.x (rilasciato a fine 2025): l'oggetto attribution per INP contiene longAnimationFrameEntries, popolato automaticamente dalla libreria. Prima della 4.x dovevi correlare a mano usando i timestamp, ed era una fonte di bug perché i buffer di due observer separati possono desincronizzarsi. Ora è integrato. Vedi il changelog di web-vitals per i dettagli sulle novità del 2026.
Inviare i dati LoAF al tuo endpoint RUM
In produzione l'overhead più insidioso non è la registrazione dell'observer ma la serializzazione. Un'entry LoAF con 30 script ha un payload JSON di 8-15 kB; se lo invii per ogni entry saturi la banda del client su mobile. La strategia che raccomando: campionare all'1-5% delle sessioni, filtrare per blockingDuration >= 100 ms, e batch-inviare con navigator.sendBeacon su visibilitychange.
Alcuni dettagli non ovvi che ho imparato a mie spese: (1) tronca sourceURL a 40 caratteri prima di serializzare, altrimenti gli URL firmati di S3 gonfiano il payload; (2) usa solo i primi 3 script per entry, il long tail statistico non aggiunge segnale; (3) mai inviare LoAF via fetch. Solo sendBeacon garantisce la consegna al page unload. Se il tuo backend è già configurato per CrUX-style data, aggiungere LoAF costa 30 righe di codice sul client e zero sull'infra.
Ottimizzare le cinque cause più comuni di long frame
Dopo aver analizzato LoAF trace su una cinquantina di siti nel 2026, le cause si distribuiscono secondo una Pareto piuttosto stretta. Ecco le cinque che troverai per prime, in ordine di frequenza.
1. Hydration di framework SPA (React, Vue, Svelte)
L'hydration iniziale è la fonte numero uno di long frame > 200 ms su pagine SPA. Il fix moderno è selective/progressive hydration: React 19 e Next.js 15 con App Router usano boundary <Suspense> per idratare solo i componenti visibili. LoAF ti dice esattamente quali componenti causano il picco, cercando sourceFunctionName che iniziano con hydrate o mount.
2. Terze parti sincrone (tag manager, consent, chat widget)
Cerca sourceURL che contengono gtm.js, hotjar, zendesk, onetrust. Se il loro blockingDuration aggregato supera il 30% del budget di frame, spostali dietro un requestIdleCallback o carica async con un facade. È lo stesso pattern che descrivo nella guida sulle ottimizzazioni LCP per immagini, applicato al JS.
3. Layout thrashing
Segnale: forcedStyleAndLayoutDuration > 20 ms su un singolo script. Cause tipiche: leggere offsetHeight/getBoundingClientRect in un ciclo che scrive nel DOM. Fix: batchare letture e scritture (pattern read-write), o usare ResizeObserver/IntersectionObserver.
4. Listener di scroll o pointer non passivi
invokerType: "event-listener" con invoker: "document.onscroll" e durate ripetute ~30 ms. Aggiungi {passive: true} agli addEventListener di scroll, touchstart, wheel. È un fix di due righe che ho visto ridurre l'INP p75 da 380 ms a 190 ms su un menu mobile.
5. Rendering client-side di grandi liste
Se vedi scripts con nomi tipo renderList, createRow, e duration > 100 ms, hai bisogno di virtualizzazione (react-virtual, tanstack-virtual) o di content-visibility: auto in CSS per delegare al browser il lazy paint.
Compatibilità browser e strategia di fallback
Al luglio 2026 la Long Animation Frames API è disponibile in tutti i browser Chromium (Chrome, Edge, Opera, Samsung Internet, Arc) dalla versione 123 in poi. Safari 18 e Firefox 128 non la espongono, e i team WebKit/Gecko non hanno annunciato tempistiche. Il consenso della TAG è che LoAF entri in Interoperable Web Features 2027, quindi realisticamente conta su Chromium-only per tutto il 2026. Le specifiche complete sono nel W3C Working Draft della Long Animation Frames API.
La strategia che consiglio: usa PerformanceObserver.supportedEntryTypes.includes("long-animation-frame") come feature detect, poi tieni comunque un observer per longtask come fallback per Safari/Firefox. I dati non saranno confrontabili al millisecondo, ma ti danno un baseline di detezione. Su Chrome puoi correlare i due observer per validare il tuo pipeline RUM: le entry Long Tasks devono cadere all'interno di LoAF con blocking simile.
Nel medio termine è probabile che LoAF venga esteso con renderBlockingResources (già nel draft) e con integrazioni per Web Workers e Shared Workers. Segui il repository ufficiale su GitHub se vuoi anticipare i cambiamenti nel tuo tooling.
Riepilogo operativo per il debug quotidiano
Quando ricevo un ticket "l'INP è brutto in produzione", la mia procedura standard nel 2026 è: (1) attivare LoAF con buffered: true in DevTools Console della pagina incriminata, (2) triggerare l'interazione, (3) chiamare window.__loaf.forEach(summarizeLoAF), (4) ordinare per blockingDuration, (5) prendere il top script e cercare il sourceCharPosition nella source map. Da segnalazione a ipotesi in meno di cinque minuti. Se questo flusso ti sembra troppo veloce, è perché prima di LoAF prendeva ore.
Domande frequenti
Che differenza c'è tra un long task e un long animation frame?
Un long task è un singolo blocco di JS > 50 ms. Un long animation frame è un intero frame di rendering > 50 ms, che può contenere più task e include anche le fasi di style, layout e paint. LoAF è quindi un'aggregazione superiore e con attribuzione script per riga, cosa che Long Tasks non ha.
Long Animation Frames API funziona su Safari e Firefox?
No, al luglio 2026 solo i browser Chromium (Chrome, Edge, Opera, Arc, Samsung Internet) dalla versione 123 espongono la Long Animation Frames API. Safari 18 e Firefox 128 non la implementano ancora e non hanno roadmap pubblica. Usa PerformanceObserver.supportedEntryTypes come feature detect e mantieni Long Tasks come fallback su altri browser.
Come si misura l'INP con la Long Animation Frames API?
Usa web-vitals 4.x con onINP e leggi attribution.longAnimationFrameEntries. La libreria correla automaticamente le entry LoAF che ricadono nella finestra temporale dell'interazione INP, evitando desincronizzazioni fra observer separati. Prima della 4.x andava fatto a mano confrontando startTime.
Qual è l'overhead di performance della Long Animation Frames API in produzione?
Nella mia esperienza su siti da 20 M pageview al mese, l'overhead di un singolo observer LoAF è sotto lo 0.5% del CPU main thread. Diventa rilevante solo se registri più observer in parallelo o se serializzi tutte le entry senza filtrare per blockingDuration. Con sampling al 5% e filtro > 100 ms è impercettibile.
Come collegare sourceCharPosition al file sorgente originale?
sourceCharPosition è un offset in caratteri sul bundle servito. Per risalire a riga/colonna nel sorgente originale carica la source map e usa source-map-consumer: consumer.originalPositionFor({line, column}) dove line/column li derivi convertendo l'offset. La maggior parte dei tool RUM (Sentry, Datadog RUM) lo fa già nativamente se hai caricato la source map.
Come misurare, diagnosticare e sbloccare il back/forward cache per portare le navigazioni indietro/avanti sotto i 100 ms. Con dati RUM reali, l'API NotRestoredReasons, DevTools e best practices frontend testate su siti in produzione nel 2026.
Ottimizza i font web nel 2026 con WOFF2, font-display, preload, variable fonts, subsetting e size-adjust: ridurre CLS e LCP del 70-90% con esempi pratici.
Guida pratica a HTTP 103 Early Hints per ridurre il TTFB percepito: configurazione su Cloudflare, Vercel, Nginx e Node.js con esempi di codice e misurazione dell'impatto reale su LCP nel 2026.