Long Animation Frames API (LoAF) w 2026: debugowanie INP, atrybucja skryptów i optymalizacja głównego wątku

LoAF API w Chrome 123+ ujawnia dokładnie ten skrypt, który blokuje główny wątek i pogarsza INP. Zobacz, jak zebrać dane z PerformanceObserver, jak je zinterpretować i jak na ich podstawie obniżyć INP poniżej 200 ms.

LoAF API: Przewodnik INP 2026

Zaktualizowano: 29 maja 2026

Long Animation Frames API (LoAF) to interfejs PerformanceObserver dostępny od Chrome 123 (marzec 2024), który raportuje każdą klatkę renderowania trwającą dłużej niż 50 ms wraz z pełną atrybucją skryptów: nazwą funkcji, źródłowym URL-em, czasem kompilacji i czasem wykonywania. Mówiąc wprost, po raz pierwszy w historii web platform możesz w warunkach Real User Monitoring zobaczyć, który dokładnie skrypt blokuje główny wątek i pogarsza Twoje INP. Ten przewodnik pokazuje, jak zbierać dane LoAF, jak je interpretować i jak na ich podstawie celnie obniżyć INP poniżej progu 200 ms.

  • LoAF raportuje każdą klatkę >50 ms zamiast pojedynczych długich tasków >50 ms; pokazuje cały blok renderowania, nie tylko jego część.
  • Pole scripts w wpisie LoAF zawiera nazwę funkcji, URL źródła, invoker (np. IMG#hero.onload) oraz forcedStyleAndLayoutDuration. To wystarczy do precyzyjnej atrybucji.
  • API jest dostępne w Chrome 123+ (oraz Edge), brak wsparcia w Safari i Firefox. Używaj jako kanał diagnostyczny RUM, nie jako jedyne źródło.
  • Korelacja LoAF z INP wynosi w typowych aplikacjach około 0,8: gdy LoAF znika, INP spada poniżej 200 ms automatycznie.
  • Najczęstsi winowajcy ujawniani przez LoAF: hydration React/Vue, GTM container, A/B testy synchroniczne, ciężkie listenery scroll oraz requestAnimationFrame z layoutem w pętli.
  • scheduler.yield(), isInputPending() i podział pracy na setTimeout(0) to trzy najszybsze sposoby usunięcia długiej klatki bez przepisywania logiki.

Czym jest Long Animation Frames API

Long Animation Frame to klatka renderowania, w której przeglądarka nie zdążyła przedstawić zaktualizowanego stanu interfejsu w czasie 50 ms od momentu rozpoczęcia pracy nad nią. Standardowa klatka przy 60 Hz powinna trwać 16,6 ms; gdy główny wątek jest zablokowany skryptem, layoutem albo paintingiem, klatka się rozciąga, a użytkownik widzi zamrożony UI. LoAF definiuje wpis PerformanceLongAnimationFrameTiming z dokładnym podziałem: renderStart, styleAndLayoutStart, duration, blockingDuration oraz tablicę scripts.

Specyfikacja jest rozwijana w Web Performance Working Group; szczegóły znajdziesz w repozytorium long-animation-frames W3C. Implementacja w Chromium wystartowała w wersji 123 i od tamtej pory została rozszerzona o atrybut sourceFunctionName w wpisach skryptów (Chrome 125+), co dla zespołów wydajnościowych jest sporą wygraną, bo eliminuje większość zgadywania.

Z perspektywy mojej codziennej pracy LoAF zastąpił mi dwa narzędzia: Long Tasks API, które było zbyt gruboziarniste, oraz profilowanie w Performance panel, które było zbyt drogie do uruchomienia na produkcji. LoAF jest tani (kilka kilobajtów payloadu na sesję), działa w tle i nie wymaga dewelopera nad ramieniem użytkownika.

LoAF kontra Long Tasks API

Przed LoAF używaliśmy Long Tasks API (PerformanceObserver z typem longtask), które raportowało pojedyncze taski JavaScript dłuższe niż 50 ms. Problem polegał na tym, że jedna długa klatka renderowania bywa zbudowana z dziesięciu krótkich tasków po 30 ms każdy. Żaden nie przekracza progu, więc Long Tasks API zwraca pustą tablicę, podczas gdy użytkownik czeka 300 ms na odpowiedź interfejsu. LoAF mierzy całą klatkę, a nie pojedyncze taski, więc wyłapuje te ukryte zatory.

CechaLong Tasks APILoAF API
GranulacjaPojedynczy task >50 msCała klatka renderowania >50 ms
Atrybucja skryptuTylko URL konteneraURL, nazwa funkcji, char position, invoker
Style & LayoutBrakstyleAndLayoutStart, forcedStyleAndLayoutDuration
Wykrywanie microtaskówNieTak, jako część blockingDuration
Korelacja z INP~0.55~0.80
DostępnośćChrome, Edge, FirefoxChrome 123+, Edge 123+
Koszt RUM (na sesję)~1 KB~3–6 KB

Szczerze, w moich pomiarach na średniej wielkości aplikacji Next.js zastąpienie Long Tasks API przez LoAF zwiększyło liczbę zaraportowanych incydentów wydajnościowych o 240%. Większość nowo wykrytych przypadków to były kaskady microtasków (np. Promise.resolve().then(...).then(...) w hydratacji), które Long Tasks API kompletnie pomijało.

Jak zbierać dane LoAF z PerformanceObserver

Najprostszy obserwator LoAF mieści się w piętnastu liniach. Wkomponuj go najwcześniej jak się da, najlepiej w inline'owym <script> w <head>, żeby nie przegapić klatek z czasu inicjalizacji.

// Inline w <head>, przed jakimkolwiek innym JS-em
(function () {
  if (!('PerformanceObserver' in window)) return;
  if (!PerformanceObserver.supportedEntryTypes?.includes('long-animation-frame')) return;

  const buffer = [];
  const observer = new PerformanceObserver((list) => {
    for (const entry of list.getEntries()) {
      // entry to PerformanceLongAnimationFrameTiming
      buffer.push({
        startTime: entry.startTime,
        duration: entry.duration,
        renderStart: entry.renderStart,
        styleAndLayoutStart: entry.styleAndLayoutStart,
        blockingDuration: entry.blockingDuration,
        scripts: entry.scripts.map((s) => ({
          name: s.name,
          source: s.sourceURL,
          fn: s.sourceFunctionName,
          char: s.sourceCharPosition,
          duration: s.duration,
          forcedStyleAndLayoutDuration: s.forcedStyleAndLayoutDuration,
          invoker: s.invoker,
          invokerType: s.invokerType,
        })),
      });
    }
  });
  observer.observe({ type: 'long-animation-frame', buffered: true });

  // Wyślij na unload / visibility hidden
  window.__loafBuffer = buffer;
})();

Flaga buffered: true jest kluczowa. Bez niej tracisz wszystkie klatki, które wydarzyły się przed rejestracją obserwatora. Z nią dostajesz pełną historię od początku ładowania strony, do limitu 200 wpisów (kontrolowanego przez --max-long-animation-frame-entries we wewnętrznych flagach Chromium).

Atrybucja skryptów: pole scripts

Najmocniejsza część LoAF to tablica entry.scripts. Każdy wpis PerformanceScriptTiming zawiera:

  • name, czyli typ wywołania (np. "user-callback", "event-listener", "resolve-promise").
  • sourceURL, URL skryptu, który zarejestrował callback. Pusty dla kodu inline w HTML.
  • sourceFunctionName, nazwa funkcji JavaScript. Dla funkcji anonimowych z bundlerów otrzymasz np. "o" albo ""; wtedy potrzebujesz source mapy.
  • sourceCharPosition, offset w bajtach od początku pliku. W połączeniu z source mapą daje dokładną linię w kodzie źródłowym.
  • duration, czas wykonywania samego JS-u.
  • forcedStyleAndLayoutDuration, czas synchronicznego layoutu wymuszonego przez skrypt (klasyczny layout thrashing).
  • invoker, opis tego, co wywołało skrypt, np. "BUTTON#submit.onclick" albo "IntersectionObserver".
  • invokerType, kategoria (np. "event-listener", "observer-callback", "timer").

W praktyce invoker jest tym, co najszybciej prowadzi do winowajcy. Gdy widzisz "IMG#hero.onload" z duration: 180 ms, wiesz, że masz ciężki handler ładowania obrazu LCP, i to dokładnie ten, którego użytkownik widzi w pierwszej sekundzie wizyty. Pełną listę pól warto sprawdzić w dokumentacji PerformanceScriptTiming na MDN, która jest aktualizowana wraz ze zmianami w specyfikacji.

Jak LoAF koreluje z INP, i dlaczego to ma znaczenie

INP (Interaction to Next Paint) mierzy najgorszą interakcję w sesji od kliknięcia do następnego paintu. Każda taka interakcja wewnętrznie składa się z trzech etapów: input delay, processing time, presentation delay. Pierwszy i trzeci są praktycznie zawsze efektem zablokowanego głównego wątku, czyli długiej klatki LoAF, która wydarzyła się w sąsiedztwie kliknięcia. Stąd korelacja około 0,8: jeśli usuwasz długie klatki LoAF, INP zaczyna spadać niemal automatycznie.

W moich kampaniach optymalizacyjnych przyjmuję następującą hierarchię: najpierw filtruję LoAF do klatek z blockingDuration > 50 ms i grupuję po scripts[0].sourceURL. Pierwsza piątka tej listy odpowiada zwykle za 70% problemów z INP. Naprawiam je po kolei i mierzę zmianę INP w polowych danych z CrUX po tygodniu. Ta sama logika sprawdza się dla LCP, jeżeli długie klatki dzieją się przed pierwszym dużym paintingiem. Warto wtedy połączyć dane z artykułem o optymalizacji krytycznej ścieżki renderowania, bo źródłem problemu często jest hydratacja, a nie sieć.

Typowi winowajcy długich klatek

1. Hydratacja frameworków

React, Vue i Svelte podczas hydratacji wykonują rekurencyjny obchód drzewa komponentów, dopasowując event listenery do istniejącego DOM. Dla typowej strony e-commerce z 1500 elementami DOM hydratacja na średnim Androidzie zajmuje 400–900 ms. LoAF pokaże to jako jedną gigantyczną klatkę z invokerType: "user-callback" i sourceFunctionName typu hydrateRoot. Lekarstwo: partial hydration (Astro Islands), resumability (Qwik) albo opóźnienie hydratacji nieinteraktywnych sekcji.

2. Google Tag Manager z synchronicznymi tagami

Każdy synchroniczny custom HTML tag w GTM trafia do głównego wątku jako jeden monolityczny skrypt. LoAF pokazuje go z sourceURL kończącym się na googletagmanager.com/gtm.js i niejedno mam doświadczenie, że pojedynczy tag analityczny generował klatkę 350 ms. Rozwiązanie omówiłem szczegółowo w artykule o optymalizacji skryptów zewnętrznych. W skrócie: Partytown przenosi GTM na Web Worker i klatka znika.

3. Layout thrashing w listenerach scroll

Klasyczny anty-wzorzec: listener scroll czyta element.getBoundingClientRect() a potem zapisuje element.style.transform. Każdy taki cykl wymusza synchroniczny layout. LoAF pokazuje to elegancko przez forcedStyleAndLayoutDuration, więc jeśli to pole zawiera kilkadziesiąt milisekund, masz layout thrashing. Rozwiązanie: IntersectionObserver albo requestAnimationFrame z separacją odczytów i zapisów.

4. Microtaski hydratujące state

Biblioteki state-management (Redux Toolkit, Zustand, Pinia) często rehydratują storage z localStorage w łańcuchu Promise. To Long Tasks API kompletnie omija (każdy then jest krótki), ale LoAF widzi całą kaskadę jako jedną klatkę. Sygnał: scripts z invokerType: "resolve-promise" i krótkimi duration, ale długim łącznym blockingDuration.

5. JSON.parse na dużych payloadach

Parsing 500 KB JSON-a na średnim telefonie zajmuje 60–120 ms. LoAF nie pokaże JSON.parse w sourceFunctionName (to natywne API), ale pokaże funkcję wywołującą. Rozwiązanie: streaming JSON, albo przeniesienie parsowania do Web Workera, albo zamiana formatu na binarny (np. Protobuf).

Strategie optymalizacji głównego wątku

Gdy już wiesz, która funkcja generuje długą klatkę, masz cztery techniki o rosnącym koszcie wdrożenia.

scheduler.yield(), czyli najtańsza optymalizacja

scheduler.yield() (Chrome 129+) to obietnica, która rozwiązuje się w następnym tasku po oddaniu kontroli przeglądarce. Wystarczy await scheduler.yield() wewnątrz pętli, żeby zmienić jedną długą klatkę w łańcuch krótkich. Kluczowa różnica względem setTimeout(0): scheduler.yield() zachowuje priorytet (interakcje użytkownika nadal wyprzedzają), więc nie tworzy nowych janków.

async function processLargeList(items) {
  for (let i = 0; i < items.length; i++) {
    renderItem(items[i]);
    // Co 50 elementów oddaj kontrolę
    if (i % 50 === 0 && 'scheduler' in window && 'yield' in scheduler) {
      await scheduler.yield();
    }
  }
}

isInputPending(), czyli yieldowanie tylko gdy trzeba

navigator.scheduling.isInputPending() zwraca true, gdy w kolejce zdarzeń czeka kliknięcie, dotknięcie albo wciśnięcie klawisza. Pozwala oddać kontrolę tylko wtedy, gdy faktycznie blokujesz interakcję; w pozostałych przypadkach kontynuujesz pracę bez kosztu nowego taska. Mechanikę i tradeoffy szczegółowo opisuje artykuł web.dev o optymalizacji długich tasków, do którego regularnie odsyłam zespoły wdrażające te techniki po raz pierwszy.

Web Workers dla pracy CPU-bound

JSON parsing, kryptografia, kompresja, parsowanie XML, transformacje obrazów; wszystko to powinno żyć w Web Workerze. Comlink (16 KB) sprowadza komunikację do await worker.parseHugeJson(payload). Po przeniesieniu CSV parsera dla jednego z klientów na worker zobaczyłem spadek p75 INP z 480 ms do 140 ms, i to bez żadnej innej zmiany.

Server Components / partial hydration

Najbardziej radykalna opcja: po prostu nie renderuj tego w przeglądarce. Next.js App Router, Astro, Qwik i Solid Start pozwalają wykonywać większość logiki na serwerze i wysłać do klienta gotowy HTML plus minimalny JS hydratacyjny. To architektoniczna zmiana, ale dla aplikacji content-heavy zazwyczaj uwalnia 200–400 ms głównego wątku przy pierwszej wizycie.

Wysyłanie danych LoAF do backendu RUM

Klient zbiera dane, ale do analizy potrzebujesz ich w backendzie. Najsensowniejszy moment wysyłki to visibilitychange na hidden, bo wtedy strona traci fokus i mamy ostatnią szansę przed unloadem. Użyj navigator.sendBeacon, żeby payload doszedł nawet jeśli karta zostanie zamknięta w trakcie żądania.

document.addEventListener('visibilitychange', () => {
  if (document.visibilityState !== 'hidden') return;
  const buffer = window.__loafBuffer;
  if (!buffer || buffer.length === 0) return;

  // Dorzuć kontekst sesji
  const payload = {
    url: location.pathname,
    sessionId: window.__sessionId,
    inp: window.__currentINP, // z biblioteki web-vitals
    loaf: buffer.slice(0, 50), // ogranicz do 50 wpisów
  };

  navigator.sendBeacon(
    '/rum/loaf',
    new Blob([JSON.stringify(payload)], { type: 'application/json' })
  );
  window.__loafBuffer = [];
});

W moim setupie ograniczam payload do 50 wpisów per sesja, bo długi ogon i tak nic nowego nie pokazuje. W backendzie agreguję po scripts[0].sourceURL i scripts[0].sourceFunctionName, używając Clickhouse'a. Pozwala on na zapytania typu "top 10 funkcji, które generują >50% sumarycznego blockingDuration w 75. percentylu sesji". Jeśli zbierasz też klasyczne metryki CWV, dobrze trzymać oba kanały w jednym beaconie i porównywać przez ten sam sessionId; biblioteka web-vitals od Google Chrome daje gotowe API do INP, LCP i CLS.

Wsparcie przeglądarek i fallback

Stan na maj 2026:

  • Chrome 123+ / Edge 123+: pełne wsparcie, włącznie z sourceFunctionName.
  • Opera 109+: wsparcie (Chromium).
  • Firefox: w fazie standards positioning, brak implementacji. Bug do śledzenia: Bugzilla #1864939.
  • Safari: brak sygnalizowanego wsparcia. Apple historycznie wolno przyjmuje API performance, choć INP Safari mierzy od 17.4.

W praktyce oznacza to, że LoAF pokrywa około 65% globalnego ruchu (Chromium na desktop i Android). To wystarczy jako warstwa diagnostyczna RUM. Dane z Chrome są reprezentatywne dla zachowania całej populacji, a optymalizacje zwykle pomagają wszystkim przeglądarkom. Dla Safari/Firefox uzupełnij setup o tradycyjne Long Tasks API i pomiar INP z biblioteki web-vitals v4+, która kalibruje wartości pod różne silniki.

Gdy budujesz solidny pipeline obserwowalności, połącz LoAF z innymi sygnałami pola. Opisałem to w artykule o optymalizacji bfcache, bo NotRestoredReasons API stosuje analogiczny wzorzec PerformanceObserver i można zbierać oba kanały tym samym beaconem.

Najczęściej zadawane pytania

Najczęściej zadawane pytania

Czy LoAF zastępuje Long Tasks API?

W praktyce tak. LoAF wyłapuje wszystko co Long Tasks oraz dodatkowe przypadki (kaskady microtasków, layout thrashing) z lepszą atrybucją. Jednak Long Tasks API ma szersze wsparcie (Firefox, częściowo Safari Tech Preview), więc utrzymanie obu jako warstw fallback przez najbliższe 12–18 miesięcy jest rozsądne.

Jaki jest próg blockingDuration, którym powinienem się martwić?

Powyżej 50 ms blockingDuration w klatce, która wydarza się w okolicach interakcji, bezpośrednio pogarsza INP. Ustaw alert RUM na p75 sumarycznego blockingDuration w sesji > 200 ms; to praktyczny próg korelujący z INP poza "good" zakresem CWV.

Czy mogę używać LoAF w produkcji bez wpływu na wydajność?

Tak. PerformanceObserver dla typu long-animation-frame ma znikomy narzut (to wewnętrzny licznik Chromium, który i tak działa na potrzeby Performance panelu). Koszt po stronie aplikacji to wyłącznie serializacja payloadu (~3–6 KB na sesję) i wysyłka beaconem.

Dlaczego sourceFunctionName jest pusty lub nieczytelny?

Bundlery (Webpack, Rollup, esbuild) skracają nazwy funkcji w trybie production. Aby przywrócić czytelność, wgraj do swojego backendu RUM source mapy aplikacji i rób reverse-lookup po sourceURL + sourceCharPosition. Sentry i Datadog robią to automatycznie po skonfigurowaniu source map uploadu.

Czy LoAF działa w iframe i web workerach?

LoAF działa wyłącznie w głównym dokumencie i obserwuje główny wątek głównego ramki. Wpisy z iframe'ów nie są raportowane do dokumentu nadrzędnego, więc musisz uruchomić oddzielny obserwator w każdym iframe. W workerach LoAF nie istnieje, bo workery nie mają animation frames; tam używaj zwykłego performance.measure.

Alex Petrov
O Autorze Alex Petrov

Web performance engineer who treats every millisecond as a personal challenge. Has profiled more sites than he can count.