Long Animation Frames API в 2026: как диагностировать INP с помощью LoAF

Long Tasks API устарел для диагностики INP. Long Animation Frames API в Chrome 123+ даёт детальную атрибуцию скриптов, связь с INP через web-vitals 4.0 и точные данные о forced reflow. Гайд с примерами кода и чек-листом внедрения в RUM.

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 APILong 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-callbacksetTimeout, setInterval, requestIdleCallback
  • resolve-promise и reject-promise — продолжение цепочки промисов
  • script-block — синхронный <script> в HTML
  • module-script-block — модульный <script type="module">
  • observer-callbackIntersectionObserver, 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-телеметрии:

  1. Подключите web-vitals 4+ с атрибуцией и начните логировать INP вместе с longAnimationFrameEntries в свой RUM. Если своего RUM нет — DebugBear, SpeedCurve, RUMvision и Sentry поддерживают LoAF из коробки.
  2. Сегментируйте по перцентилям. Смотрите p75 и p95 INP по странице, типу устройства, стране. Низкая 4G-связь и Android начального уровня — стабильные доноры плохого INP.
  3. Найдите страницы с худшим p75 INP и для каждой выведите топ-5 скриптов по blockingDuration.
  4. Классифицируйте LoAF по типу invoker. Если львиная доля приходит от event-listener — оптимизируйте обработчики (debounce, разбиение на scheduler.yield()). Если от user-callback — ищите тяжёлые таймеры. Если от observer-callback — циклы обратной связи между Resize/IntersectionObserver и DOM.
  5. Проверьте forcedStyleAndLayoutDuration. Если оно ненулевое — у вас классический layout thrashing: чтение и запись DOM в одном цикле. Лекарство — батчинг через requestAnimationFrame.
  6. Подтвердите фикс через 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 в продакшен

  1. Обновите web-vitals до версии 4.0 или выше — без неё связь LoAF и INP вы не получите.
  2. Добавьте crossorigin="anonymous" ко всем своим <script> — иначе атрибуция будет обрезанной.
  3. Подпишитесь на long-animation-frame с buffered: true, чтобы поймать ранние LoAF при загрузке.
  4. Настройте порог отчётности (blockingDuration > 150ms) и лимит на сессию (≤10 записей).
  5. Отправляйте данные через navigator.sendBeacon на visibilitychange — это надёжнее, чем fetch на beforeunload.
  6. В RUM-дашборде разбивайте по invokerType, sourceURL и forcedStyleAndLayoutDuration — это даёт три разных угла на одну проблему.
  7. Сравнивайте 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.

Об авторе Editorial Team

Our team of expert writers and editors.