INP стал официальной метрикой Core Web Vitals ещё в марте 2024-го, и с тех пор у нас, разработчиков, появился чёткий KPI отзывчивости — но, честно говоря, ровно ноль нормальных инструментов, чтобы понять, что именно тормозит интерфейс. Long Tasks API показывает лишь 50-миллисекундные «глыбы» на главном потоке: ни атрибуции к скриптам, ни времени рендеринга, ни связи с конкретным взаимодействием. Глухо.
В 2026 году эту проблему наконец-то решает Long Animation Frames API (или просто LoAF). Он стабильно работает в Chrome 123+ и даёт детальную атрибуцию по скриптам, стилям, лейауту и презентации.
Эта статья — продолжение нашего гайда по оптимизации INP. Если в нём мы говорили как чинить медленные взаимодействия, то здесь разберём, как их находить в реальном продакшене — с помощью LoAF, web-vitals v4 и старого доброго PerformanceObserver.
Что такое Long Animation Frame и почему он лучше Long Task
Long Animation Frame — это рендер-фрейм, который занял более 50 мс от начала первой задачи до момента, когда браузер обновил пиксели на экране (или решил, что обновление не нужно). Ключевое отличие от Long Task: LoAF измеряет весь путь до пикселя, а не только выполнение JavaScript.
Сравним, что отдают обе API для одной и той же тяжёлой кнопки:
| Параметр | Long Tasks API | Long Animation Frames API |
|---|---|---|
| Длительность скриптов | + (одна общая) | + (по каждому скрипту отдельно) |
| Источник скрипта (URL, функция) | — | + |
| Время лейаута и стилей | — | + (styleAndLayoutStart) |
| Время рендеринга | — | + (renderStart, paintTime) |
| Принудительные reflow (forced layout) | — | + (forcedStyleAndLayoutDuration) |
| Блокирующая длительность | — | + (blockingDuration) |
| Связь с INP-взаимодействием | — | + (через web-vitals v4) |
На практике это означает вот что: вместо «у вас была долгая задача 320 мс» вы видите «320 мс — из них 180 мс выполнялся tracker.js:42 в обработчике onClick, 95 мс ушло на forced reflow в App.tsx:118, ещё 45 мс — на финальный paint». Это уровень детализации, ради которого раньше приходилось вручную запускать DevTools и ловить проблему в живой среде. То есть, по сути, гадать.
Базовая настройка PerformanceObserver для LoAF
Подключение API занимает буквально пять строк. Сначала проверим поддержку — на середину 2026 года это около 81% мирового трафика (Chrome, Edge, Opera, Samsung Internet; Firefox в активной разработке, Safari пока без поддержки):
if (
typeof PerformanceObserver !== 'undefined' &&
PerformanceObserver.supportedEntryTypes?.includes('long-animation-frame')
) {
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
console.log(entry.toJSON());
}
});
observer.observe({ type: 'long-animation-frame', buffered: true });
}
Флаг buffered: true здесь критичен. Он отдаёт LoAF, случившиеся до подписки — а это особенно важно для замеров стартовой загрузки страницы. Без него вы тихо пропустите все долгие фреймы, которые произошли в первые сотни миллисекунд жизни страницы. И поверьте, их там немало.
Структура записи PerformanceLongAnimationFrameTiming
Вот реальный объект, который вы получите для затормозившего фрейма:
{
"name": "long-animation-frame",
"entryType": "long-animation-frame",
"startTime": 12483.7,
"duration": 312,
"renderStart": 12760.4,
"styleAndLayoutStart": 12780.1,
"firstUIEventTimestamp": 12490.2,
"blockingDuration": 268,
"scripts": [
{
"name": "script",
"entryType": "script",
"startTime": 12490.5,
"duration": 184,
"invoker": "BUTTON#cta.onclick",
"invokerType": "event-listener",
"executionStart": 12492.1,
"sourceURL": "https://example.com/static/tracker.js",
"sourceFunctionName": "trackEvent",
"sourceCharPosition": 4218,
"pauseDuration": 0,
"forcedStyleAndLayoutDuration": 0
}
]
}
Самые ценные поля — blockingDuration (сколько именно времени фрейм блокировал отзывчивость), scripts[].invoker (источник: обработчик события, таймер, промис, IntersectionObserver) и связка sourceURL + sourceFunctionName (точный файл и функция). Всё остальное — приятные бонусы.
Атрибуция скриптов: какой код тормозит ваш сайт
Самый частый сценарий — узнать, какие скрипты дают наибольший вклад в LoAF. Это особенно ценно для отлова сторонних скриптов: аналитики, чатов поддержки, A/B-тестирования. Короче, всего того, что добавили «на пару дней» три года назад.
const scriptStats = new Map();
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
for (const script of entry.scripts) {
const url = script.sourceURL || '(anonymous)';
const stats = scriptStats.get(url) || { count: 0, totalDuration: 0, blocking: 0 };
stats.count += 1;
stats.totalDuration += script.duration;
stats.blocking += script.forcedStyleAndLayoutDuration;
scriptStats.set(url, stats);
}
}
});
observer.observe({ type: 'long-animation-frame', buffered: true });
// Через 30 секунд выводим топ виновников
setTimeout(() => {
const sorted = [...scriptStats.entries()]
.sort(([, a], [, b]) => b.totalDuration - a.totalDuration)
.slice(0, 10);
console.table(sorted);
}, 30000);
Запустив этот сниппет на типичной e-commerce странице, вы почти наверняка увидите в топе три категории: собственный bundle (особенно если в нём гидратация React или Vue), пиксели рекламных сетей и виджеты чата. И это, по сути, уже пол-ответа на вопрос «куда уходит INP».
Атрибуция через invoker: какой код запустил тяжёлую работу
Поле invoker отвечает на вопрос «кто инициировал этот скрипт». Возможные значения:
event-listener— обработчик DOM-события (например,BUTTON#cta.onclick)user-callback—setTimeout,setInterval,requestIdleCallbackresolve-promiseиreject-promise— продолжение цепочки промисовscript-block— синхронный<script>в HTMLmodule-script-block— модульный<script type="module">observer-callback—IntersectionObserver,MutationObserver,ResizeObserver
Если в логах LoAF вы видите массу записей с invokerType: "observer-callback" и invoker, указывающим на ResizeObserver, — это типичный признак цикла обратной связи между измерением размера и изменением CSS, который, увы, знаменито роняет INP в карусельных компонентах. Лично я наступал на эти грабли как минимум трижды, и каждый раз думал, что «ну вот сейчас-то точно последний».
Связь LoAF с конкретным INP-взаимодействием
С версии web-vitals 4.0 (выпущенной в 2024 году) библиотека автоматически прикрепляет все LoAF, пересекающиеся с INP-взаимодействием, к атрибуции INP-метрики. Это решает главную проблему диагностики: «у пользователя X был INP 480 мс — что именно тормозило?».
import { onINP } from 'web-vitals/attribution';
onINP((metric) => {
const { attribution } = metric;
// Базовая разбивка времени
const timing = {
inputDelay: attribution.inputDelay,
processingDuration: attribution.processingDuration,
presentationDelay: attribution.presentationDelay,
target: attribution.interactionTarget, // CSS-селектор элемента
};
// Все LoAF, попавшие в окно взаимодействия
const loafs = attribution.longAnimationFrameEntries || [];
const blockingScripts = loafs
.flatMap((loaf) => loaf.scripts)
.map((script) => ({
url: script.sourceURL,
func: script.sourceFunctionName,
invoker: script.invoker,
duration: Math.round(script.duration),
}))
.sort((a, b) => b.duration - a.duration)
.slice(0, 5);
// Отправляем в RUM
navigator.sendBeacon('/rum', JSON.stringify({
metric: 'INP',
value: metric.value,
rating: metric.rating,
timing,
blockingScripts,
}));
});
Главная фишка такого подхода в том, что вы получаете прямую цепочку «пользователь → конкретное взаимодействие → конкретный скрипт» прямо из продакшена, без необходимости воспроизводить проблему локально. По нашим наблюдениям на проектах с трафиком от 1M MAU и выше, такая RUM-связка позволяет за 1–2 спринта найти и устранить процентов 80 INP-проблем — тех самых, которые синтетика в принципе не ловит.
Практический workflow диагностики INP через LoAF
Собрали типичный план оптимизации, основанный на LoAF-телеметрии:
- Подключите web-vitals 4+ с атрибуцией и начните логировать INP вместе с
longAnimationFrameEntriesв свой RUM. Если своего RUM нет — DebugBear, SpeedCurve, RUMvision и Sentry поддерживают LoAF из коробки. - Сегментируйте по перцентилям. Смотрите p75 и p95 INP по странице, типу устройства, стране. Низкая 4G-связь и Android начального уровня — стабильные доноры плохого INP.
- Найдите страницы с худшим p75 INP и для каждой выведите топ-5 скриптов по
blockingDuration. - Классифицируйте LoAF по типу invoker. Если львиная доля приходит от
event-listener— оптимизируйте обработчики (debounce, разбиение наscheduler.yield()). Если отuser-callback— ищите тяжёлые таймеры. Если отobserver-callback— циклы обратной связи между Resize/IntersectionObserver и DOM. - Проверьте
forcedStyleAndLayoutDuration. Если оно ненулевое — у вас классический layout thrashing: чтение и запись DOM в одном цикле. Лекарство — батчинг черезrequestAnimationFrame. - Подтвердите фикс через A/B: гоните половину трафика на новую версию, сравнивайте p75 INP за 7 дней. Только реальный пользовательский трафик даёт честную картину — синтетика тут лишь подсказка.
Threshold-based reporting: не утоните в данных
На крупном сайте PerformanceObserver легко выдаёт сотни LoAF на одной сессии. Отправлять всё в аналитику — расточительно по трафику и почти бесполезно по сигналу. Рекомендуемая стратегия: порог + топ-N.
const REPORTING_THRESHOLD_MS = 150;
const MAX_LOAFS_PER_SESSION = 10;
let topLoafs = [];
const observer = new PerformanceObserver((list) => {
const candidates = list.getEntries().filter(
(entry) => entry.blockingDuration > REPORTING_THRESHOLD_MS
);
topLoafs = topLoafs
.concat(candidates)
.sort((a, b) => b.blockingDuration - a.blockingDuration)
.slice(0, MAX_LOAFS_PER_SESSION);
});
observer.observe({ type: 'long-animation-frame', buffered: true });
// Отправляем при выгрузке страницы
addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden' && topLoafs.length) {
navigator.sendBeacon(
'/rum/loaf',
JSON.stringify(topLoafs.map((e) => e.toJSON()))
);
}
});
Стартуйте с порога 200 мс — это отсекает примерно 90% шума. По мере того как сайт становится быстрее, опускайте порог до 150, потом до 100 мс. Постепенно, иначе утонете в нерелевантных записях.
Визуализация LoAF в Chrome DevTools 2026
С Chrome 130+ Long Animation Frames отображаются прямо в Performance Panel — отдельной дорожкой под основной timeline. Каждый LoAF показан цветными блоками: зелёный — выполнение скриптов, жёлтый — стили и лейаут, синий — paint, красный — forced reflow. Кликните по блоку, и в нижней панели появится stacktrace с точным sourceURL:line:column. Удобно до неприличия.
Для кастомных меток можно расширить отчётность через performance.measure() с расширенными свойствами — это покажет ваши собственные LoAF-маркеры в DevTools:
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.duration < 100) continue;
performance.measure(`LoAF (${Math.round(entry.duration)}ms)`, {
start: entry.startTime,
end: entry.startTime + entry.duration,
detail: {
devtools: {
dataType: 'track-entry',
color: entry.duration > 250 ? 'error' : 'warning',
track: 'Long Animation Frames',
trackGroup: 'Performance',
properties: [
['Blocking', `${Math.round(entry.blockingDuration)}ms`],
['Scripts', String(entry.scripts.length)],
],
},
},
});
}
});
observer.observe({ type: 'long-animation-frame', buffered: true });
Ограничения и подводные камни
LoAF — мощный, но не всемогущий инструмент. Что он не покажет:
- Cross-origin iframes. Если у вас тяжёлый виджет в iframe с другим доменом — атрибуция к нему ограничена только URL, без имени функции и позиции.
- Web Workers и Service Workers. Работа в воркерах не отображается в LoAF главного потока, даже если она блокирует основной поток через postMessage-шквал.
- Расширения браузера. Скрипты расширений не атрибутируются — пользователь с тяжёлым AdBlocker может портить вам метрики совершенно невидимо.
- No-CORS скрипты. Без
crossorigin="anonymous"у тегов<script>вы получите толькоsourceURL, без имени функции и позиции в коде. Простой фикс — добавить атрибут на все свои<script>. - Safari и (до недавнего времени) Firefox. На iOS Safari вы данных не получите — LoAF доступен только в Chromium и стабильно покрывает примерно 70% мирового трафика. Это значит, что INP-данные с iOS придётся диагностировать классическими способами.
Чек-лист внедрения LoAF в продакшен
- Обновите web-vitals до версии 4.0 или выше — без неё связь LoAF и INP вы не получите.
- Добавьте
crossorigin="anonymous"ко всем своим<script>— иначе атрибуция будет обрезанной. - Подпишитесь на
long-animation-frameсbuffered: true, чтобы поймать ранние LoAF при загрузке. - Настройте порог отчётности (
blockingDuration > 150ms) и лимит на сессию (≤10 записей). - Отправляйте данные через
navigator.sendBeaconнаvisibilitychange— это надёжнее, чемfetchнаbeforeunload. - В RUM-дашборде разбивайте по
invokerType,sourceURLиforcedStyleAndLayoutDuration— это даёт три разных угла на одну проблему. - Сравнивайте p75 LoAF между релизами — это раннее предупреждение о регрессиях INP до того, как они дойдут до Search Console.
FAQ
Чем LoAF отличается от Long Tasks API?
Long Tasks API сообщает только о JavaScript-задачах ≥50 мс на главном потоке без атрибуции к конкретным скриптам. LoAF измеряет весь рендер-фрейм (скрипты + стили + лейаут + paint), даёт детальную атрибуцию по каждому скрипту с URL и именем функции, и предоставляет ключевую метрику blockingDuration — сколько именно времени фрейм мешал отзывчивости. Long Tasks API считается устаревшим для INP-диагностики; новые проекты должны использовать LoAF.
Поддерживает ли Safari Long Animation Frames API?
На середину 2026 года — нет. WebKit не реализовал LoAF, и в публичном roadmap Apple он отсутствует. У Firefox статус «positive», и API находится в активной разработке. На практике это означает, что LoAF-данные вы получите примерно с 70% мирового трафика — этого достаточно для статистически значимой диагностики, но не для тотального покрытия. Для iOS используйте классический подход через Long Tasks API и Event Timing.
Как связать LoAF с конкретным взаимодействием INP?
Самый простой способ — библиотека web-vitals версии 4.0+. В её атрибуции INP появилось поле longAnimationFrameEntries, содержащее все LoAF, временно пересекшиеся с проблемным взаимодействием. Альтернатива — вручную сопоставлять startTime и endTime у Event Timing entry и LoAF entry, но это лишняя работа: web-vitals уже делает это правильно.
Что такое blockingDuration и чем оно отличается от duration?
duration — общая длина анимационного фрейма, включая необходимые операции вроде paint и compositing. blockingDuration — это та часть, которая фактически блокировала отзывчивость пользовательского ввода (длинные задачи, превысившие 50 мс по правилам Long Tasks). Для оптимизации INP смотрите именно на blockingDuration: уменьшение общей длительности через ускорение paint обычно ничего не даёт для отзывчивости, тогда как уменьшение blockingDuration напрямую улучшает INP.
Можно ли использовать LoAF для отлова утечек памяти и forced reflow?
Forced reflow — да: поле forcedStyleAndLayoutDuration у каждого скрипта показывает время, потраченное на принудительный синхронный лейаут. Если оно стабильно ненулевое — у вас классический паттерн «прочитал layout-свойство → записал стиль → прочитал ещё раз», и нужен батчинг через requestAnimationFrame. Утечки памяти LoAF не показывает — для них используйте Memory Panel в DevTools и Performance Memory API.