Long Animation Frames API 2026: Debug INP và Third-Party Scripts với LoAF

Long Animation Frames API (LoAF) là công cụ mạnh nhất để debug INP trong sản xuất. Hướng dẫn đầy đủ về script attribution, sửa third-party CORS, và gate LoAF trong CI Playwright để giữ INP dưới 200ms tại P75 năm 2026.

LoAF API: Debug INP & Third-Party (2026)

Cập nhật: 05/07/2026

Long Animation Frames API (LoAF) là API đo lường của Chrome (ổn định từ phiên bản 123, tháng 03/2024) cho phép xác định chính xác script nào, bao gồm cả third-party, khiến một khung hình hiển thị bị trì hoãn quá 50ms. Nhờ đó, nó là công cụ mạnh nhất hiện nay để debug Interaction to Next Paint (INP) trong sản xuất. Khác với Long Tasks API cũ (chỉ đếm task >50ms trên main thread), LoAF đo trọn vòng đời của một animation frame, kèm scripts[]invoker, sourceURL, và phân rã pha style/layout. Nói thật, trong 3 quý vừa qua tôi đã dùng nó để loại bỏ 4 tag GTM, 1 A/B testing SDK và một handler React 19 mà không cần đoán mò.

  • LoAF ổn định từ Chrome 123 (03/2024) và ghi lại mọi frame >50ms, bao gồm pha render/paint mà Long Tasks không thấy.
  • Ngưỡng INP tốt là ≤200ms tại P75. Đến giữa 2026 vẫn khoảng 43% site fail chỉ tiêu này (dữ liệu CrUX).
  • PerformanceLongAnimationFrameTiming.scripts[] chỉ đúng invoker (BUTTON#buy.onclick), sourceURL, và độ trễ từ desiredExecutionStart tới executionStart.
  • Third-party không hiện URL vì thiếu CORS. Thêm crossorigin="anonymous" vào <script> là hết ẩn danh.
  • web-vitals v4 tự đính LoAF entries vào INP attribution qua attribution.longAnimationFrameEntries.
  • LoAF thuộc main-thread same-origin. Cross-origin iframe, worker và extension không lộ script, cần beacon riêng.

Long Animation Frames API là gì?

Long Animation Frames API là mở rộng của Performance API cho phép trang web quan sát các animation frame bị kéo dài quá 50ms. Nghĩa là bất kỳ chu kỳ dựng khung hình nào từ khi trình duyệt bắt đầu chuẩn bị vẽ cho đến khi commit xong đều bị đo. Spec do W3C Web Performance Working Group biên soạn, hiện đã chạy ổn định trong Chromium (Chrome, Edge, Opera, Samsung Internet) kể từ Chrome 123.

Điểm khác biệt căn bản với Long Tasks API là LoAF nhìn toàn bộ frame chứ không chỉ nhìn một task duy nhất. Một tương tác chậm hiếm khi do một task 300ms. Thường là ba task 40ms xếp chồng cộng thêm 60ms layout và 40ms paint. Long Tasks bỏ sót toàn bộ chuỗi này vì mỗi task đều dưới ngưỡng 50ms. LoAF thì gộp thành một frame duy nhất 220ms, gán nhãn từng pha, và liệt kê mọi script chạy trong đó.

Vì INP đo tổng độ trễ từ input đến lần paint kế tiếp (bao gồm input delay, processing time và presentation delay), LoAF là loại telemetry duy nhất bao phủ trọn cả ba pha đó ngay trong runtime. Đó là lý do Chrome for Developers khuyến nghị dùng LoAF làm nguồn dữ liệu tiêu chuẩn để debug INP kể từ 2024.

Vì sao INP vẫn là Core Web Vital khó pass nhất năm 2026?

Sau hơn hai năm INP thay thế First Input Delay (03/2024) trong bộ Core Web Vitals, dữ liệu CrUX quý 2/2026 cho thấy khoảng 43% site vẫn fail ngưỡng ≤200ms tại P75, cao hơn LCP (~38%) và bỏ xa CLS (dưới 20%). Mobile lệch tệ hơn desktop 60–80% điểm số.

Có ba lý do khiến tối ưu INP khó hơn LCP:

  1. INP đo mọi tương tác, không chỉ tương tác đầu tiên như FID. Site e-commerce của tôi có 30–50 tương tác/session; LCP chỉ cần thắng một trận, INP phải thắng cả 50.
  2. Presentation delay là kẻ thù ẩn. Rất nhiều dev tối ưu event handler xuống dưới 50ms rồi ngạc nhiên vì INP vẫn 400ms, bởi handler đó chạm state khiến React commit một tree lớn, style recalc quét toàn trang, paint 3 layer. LoAF là công cụ duy nhất phơi bày renderStartstyleAndLayoutStart tách biệt.
  3. Third-party script không xin phép. GTM, Segment, Optimizely, Klaviyo (mọi tag đều mount handler click/input capture trên document). Handler của bạn chạy nhanh không có nghĩa cả frame chạy nhanh.

HTTP Archive Web Almanac 2025 báo cáo dưới 25% site giữ được thời lượng task ≤50ms, tức hơn 3/4 web đang có ít nhất một LoAF trên mỗi phiên. Đọc thêm chiến lược RUM cho CLS để thấy cùng lớp beacon có thể tái dùng cho LoAF.

LoAF khác Long Tasks API ở đâu?

Đây là câu hỏi tôi nhận được mỗi lần trình bày. Trả lời ngắn: Long Tasks đo một task, LoAF đo một frame. Trả lời đầy đủ ở bảng dưới.

Tiêu chí Long Tasks API Long Animation Frames API (LoAF)
Ngưỡng kích hoạtTask chạy >50msFrame render >50ms (có thể do nhiều task nhỏ)
Bao phủ pha render/paintKhôngCó: renderStart, styleAndLayoutStart, paintTime
Script attributionChỉ container (frame/window)Chi tiết: invoker, sourceURL, sourceCharPosition
Phát hiện forced reflowKhôngCó qua forcedStyleAndLayoutDuration
Blocking durationKhôngblockingDuration tách khỏi thời lượng frame
Trình duyệt hỗ trợChrome, Edge, Firefox, Safari (một phần)Chromium 123+; Firefox và Safari đang xem xét
Chi phí runtimeRất thấpThấp, có option durationThreshold
Liên kết với INPGián tiếp qua Total Blocking TimeTrực tiếp qua thời điểm giao thoa với interaction

Thực tế thì tôi vẫn giữ Long Tasks trong lớp legacy beacon để có dữ liệu Safari, còn LoAF là nguồn sự thật cho phân tích nguyên nhân. Nếu bạn cần thêm Long Tasks context, bài tối ưu JavaScript bundle và scheduler.yield() đã đi sâu vào cách phá task >50ms.

Đọc một PerformanceLongAnimationFrameTiming entry

Một entry LoAF chứa thông tin dày hơn bất kỳ Performance API nào khác. Dưới đây là mẫu thực tế lấy trên trang product của site e-commerce tôi phụ trách (đã ẩn danh):

{
  "name": "long-animation-frame",
  "entryType": "long-animation-frame",
  "startTime": 12345.6,
  "duration": 268,
  "renderStart": 12545.1,
  "styleAndLayoutStart": 12588.2,
  "firstUIEventTimestamp": 12360.4,
  "blockingDuration": 214,
  "scripts": [
    {
      "name": "script",
      "invoker": "BUTTON#add-to-cart.onclick",
      "invokerType": "user-callback",
      "sourceURL": "https://cdn.shop.example/assets/pdp-abc123.js",
      "sourceCharPosition": 1487,
      "duration": 92,
      "executionStart": 12362.1,
      "desiredExecutionStart": 12360.5,
      "forcedStyleAndLayoutDuration": 34,
      "pauseDuration": 0
    },
    {
      "name": "script",
      "invoker": "DOCUMENT.click",
      "invokerType": "user-callback",
      "sourceURL": "https://www.googletagmanager.com/gtm.js?id=GTM-XXXX",
      "duration": 71,
      "forcedStyleAndLayoutDuration": 22
    }
  ]
}

Ba con số quan trọng nhất tôi nhìn trước:

  • duration (268ms): độ dài toàn frame. Nếu >200ms và có firstUIEventTimestamp, gần như chắc chắn INP interaction rơi vào frame này.
  • blockingDuration (214ms): thời gian block thực tế, đã trừ pause do trình duyệt tự yield. Đây là con số nên đưa vào SLA.
  • scripts[].forcedStyleAndLayoutDuration: mỗi ms ở đây là forced reflow, thường do đọc offsetHeight/getBoundingClientRect sau khi ghi DOM.

Trong ví dụ trên, handler onclick của nút "add to cart" mất 92ms, nhưng thêm 71ms nữa của một DOCUMENT.click listener do GTM đăng ký. Đó là kịch bản kinh điển tôi thấy hàng tuần.

Cách debug INP cao bằng LoAF trong sản xuất

Đây là pipeline chuẩn tôi triển khai cho site e-commerce, chạy trong module rum.ts load sau LCP:

// rum-loaf.ts — beacon LoAF frames gắn với INP interaction
import { onINP } from 'web-vitals/attribution';

type ScriptSummary = {
  invoker: string;
  sourceURL: string | null;
  duration: number;
  forcedLayout: number;
};

const MAX_FRAMES_PER_SESSION = 12;
let framesReported = 0;

function summarizeScripts(entry: PerformanceLongAnimationFrameTiming): ScriptSummary[] {
  return entry.scripts
    .map((s) => ({
      invoker: s.invoker || 'anonymous',
      sourceURL: s.sourceURL || null,
      duration: Math.round(s.duration),
      forcedLayout: Math.round(s.forcedStyleAndLayoutDuration),
    }))
    .sort((a, b) => b.duration - a.duration)
    .slice(0, 5);
}

// Bước 1: đăng ký PerformanceObserver với durationThreshold cao lúc đầu
const observer = new PerformanceObserver((list) => {
  for (const entry of list.getEntries() as PerformanceLongAnimationFrameTiming[]) {
    // Chỉ báo frame có interaction (firstUIEventTimestamp tồn tại)
    if (!entry.firstUIEventTimestamp) continue;
    if (framesReported++ >= MAX_FRAMES_PER_SESSION) return;

    navigator.sendBeacon('/rum/loaf', JSON.stringify({
      startTime: Math.round(entry.startTime),
      duration: Math.round(entry.duration),
      blockingDuration: Math.round(entry.blockingDuration),
      renderStart: Math.round(entry.renderStart),
      styleAndLayoutStart: Math.round(entry.styleAndLayoutStart),
      scripts: summarizeScripts(entry),
      url: location.pathname,
    }));
  }
});

// durationThreshold: bắt đầu ở 150ms, hạ dần khi tối ưu xong worst frames
observer.observe({ type: 'long-animation-frame', buffered: true, durationThreshold: 150 });

// Bước 2: web-vitals v4 tự đính LoAF entries liên quan tới INP interaction
onINP((metric) => {
  const { longAnimationFrameEntries } = metric.attribution;
  navigator.sendBeacon('/rum/inp', JSON.stringify({
    value: Math.round(metric.value),
    rating: metric.rating,
    interactionTarget: metric.attribution.interactionTarget,
    frames: longAnimationFrameEntries.map(summarizeScripts),
  }));
}, { reportAllChanges: false });

Ba lưu ý runtime từ kinh nghiệm chạy pipeline này ở P75 200k phiên/ngày:

  • Đặt durationThreshold cao (150–200ms) trong giai đoạn đầu. Nếu để mặc định 50ms bạn sẽ ngập beacon.
  • Chặn cứng số frame báo cáo mỗi session (tôi dùng 12). Một session dài không được phép thổi bay budget bandwidth.
  • Chỉ giữ top-5 scripts theo duration. Vượt quá là bloat log không có giá trị debug.

Script attribution: LoAF chỉ đúng thủ phạm như thế nào?

Đây là phần khiến LoAF vượt mọi công cụ hiện có. Với mỗi script chạy trong frame, LoAF trả về đối tượng PerformanceScriptTiming với các trường sau:

  • invoker: chuỗi mô tả ai gọi. Ví dụ BUTTON#buy.onclick, Window.setTimeout, IMG.onload. Nếu là handler React tổng hợp, invoker sẽ là DOCUMENT.click (delegated).
  • invokerType: user-callback, event-listener, promise-resolve, promise-reject, timeout, hoặc module-script.
  • sourceURL + sourceCharPosition + sourceFunctionName: tọa độ chính xác trong file gốc. Nếu là bundle có sourcemap, bạn có thể resolve về file TS.
  • executionStart vs desiredExecutionStart: khoảng chờ trước khi handler được chạy. Chênh lệch lớn là queue length dài, thường vì handler khác đang chạy.
  • forcedStyleAndLayoutDuration: thời gian trình duyệt phải sync layout do JS đọc thuộc tính geometry sau khi ghi DOM.

Khi bạn thấy invoker giống DOCUMENT.clicksourceURL lại trỏ về gtm.js, bạn có bằng chứng cụ thể để đưa lên bàn họp marketing. Không phải "GTM có vẻ chậm", mà "GTM đóng góp 71ms vào INP 268ms trên PDP mobile P75, dữ liệu 7 ngày". Đó là thứ khiến số hóa được giới hạn tag budget, và bàn với team dùng Partytown hoặc facade cho third-party.

Trường hợp React 19 và Signals

Với React 19, các handler đi qua bộ scheduler concurrent. LoAF sẽ ghi invoker: DOCUMENT.clicksourceURL trỏ về bundle React. Nếu sourcemap bật, sourceCharPosition resolve về đúng component. Với Signals (Preact/Solid), invoker thường là Window.requestAnimationFrame vì batch update dồn vào rAF. Hãy đọc forcedStyleAndLayoutDuration để phát hiện layout thrash trong effect. Tôi hit đúng bug này khi ship một dashboard Solid.js hồi Q1.

Vì sao script third-party không hiện URL và cách sửa

Đây là footgun lớn nhất khi triển khai LoAF trên site có nhiều tag. Nếu bạn thấy sourceURL: "" hoặc invoker: "Window.setTimeout" mà không có URL nào, gần như chắc chắn script đó là cross-origin mà không khai báo CORS. Chrome ẩn URL vì lý do bảo mật (tránh leak thông tin nội bộ qua timing).

Cách sửa:

  1. Với script bạn kiểm soát nhưng phục vụ từ CDN khác domain: thêm crossorigin="anonymous" vào thẻ <script> và bật header Access-Control-Allow-Origin: * (hoặc origin cụ thể) trên CDN.
  2. Với vendor script (GTM, Segment, TikTok Pixel…): kiểm tra vendor có publish CORS header không. GTM có; Klaviyo và một số A/B testing SDK cũ thì không, trong trường hợp đó bạn chỉ thấy tên host qua invokerType.
  3. Chrome extension: không thể sửa. LoAF sẽ ẩn hoàn toàn. Nếu số LoAF của user tăng đột biến mà bundle không đổi, nghi ngờ extension trước.

Chi tiết kỹ thuật của cơ chế này được ghi trong MDN PerformanceScriptTiming reference. Đọc phần Same-origin policy nếu bạn cần bảo vệ quyết định với security team.

Từ LoAF entry đến hành động: bảng chiến lược

Sau khi thu thập vài ngàn frame, câu hỏi tiếp theo là sửa cái gì trước. Đây là bảng ánh xạ signal-to-action tôi dùng:

Triệu chứng LoAFNguyên nhân điển hìnhHành động ưu tiên
blockingDuration > 200ms, một script chiếm >50%Handler đồng bộ nặngChuyển sang scheduler.yield() hoặc startTransition
Nhiều script third-party cùng invoker DOCUMENT.clickTag manager mount capture listenerFacade GTM hoặc offload sang Web Worker (Partytown)
forcedStyleAndLayoutDuration cao trong khi duration script thấpLayout thrashing (đọc geometry sau khi ghi DOM)Batch DOM read qua requestAnimationFrame
desiredExecutionStart lệch xa executionStartQueue dài do handler trướcYield giữa handler; giảm số listener trên document
Long frame không có firstUIEventTimestampAnimation JS (canvas, GSAP) hoặc timer heavyChuyển animation sang CSS/WAAPI; tăng interval timer
renderStart gần cuối frame, gap lớn với startTimeHandler chạy quá dài trước khi commitBreak work bằng isInputPending()

Chú ý: scheduler.yield() hiện đã ổn định trong Chrome 129 và có polyfill qua scheduler-polyfill npm package. Trên Safari và Firefox, fallback là setTimeout(0), chậm hơn nhưng vẫn phá được task.

Bundle budget: gate LoAF trong CI

Đo trong sản xuất là cần thiết nhưng chưa đủ. Tôi luôn cài regression gate trong CI. Cách setup thực tế với Playwright và LoAF:

// tests/perf/loaf-budget.spec.ts
import { test, expect } from '@playwright/test';

const BUDGETS = {
  totalBlockingDuration: 200,   // ms, tổng blocking trên critical path
  worstFrame: 250,              // ms, không frame nào được vượt
  thirdPartyBlockingShare: 0.3, // 30% budget cho third-party
};

test('PDP giữ được LoAF budget trên critical journey', async ({ page }) => {
  const frames: any[] = [];
  await page.exposeFunction('recordFrame', (f: any) => frames.push(f));

  await page.addInitScript(() => {
    new PerformanceObserver((list) => {
      for (const entry of list.getEntries()) {
        // @ts-expect-error API mới
        window.recordFrame({
          duration: entry.duration,
          blockingDuration: entry.blockingDuration,
          scripts: entry.scripts.map((s: any) => ({
            url: s.sourceURL,
            duration: s.duration,
          })),
        });
      }
    }).observe({ type: 'long-animation-frame', buffered: true });
  });

  await page.goto('/products/test-sku');
  await page.click('button[data-testid="add-to-cart"]');
  await page.waitForTimeout(1000);

  const totalBlocking = frames.reduce((s, f) => s + f.blockingDuration, 0);
  const worst = Math.max(0, ...frames.map((f) => f.duration));
  const thirdParty = frames.flatMap((f) => f.scripts)
    .filter((s: any) => s.url && !s.url.startsWith('https://shop.example'))
    .reduce((sum: number, s: any) => sum + s.duration, 0);

  expect(totalBlocking).toBeLessThan(BUDGETS.totalBlockingDuration);
  expect(worst).toBeLessThan(BUDGETS.worstFrame);
  expect(thirdParty / (totalBlocking || 1)).toBeLessThan(BUDGETS.thirdPartyBlockingShare);
});

Sau khi test này chạy trong pipeline, bất kỳ PR nào tăng blocking share của third-party đều bị fail. Tôi đã dùng nó để chặn 3 PR trong Q2/2026 định thêm chat widget vào critical journey. Marketing không vui, nhưng P75 INP giảm 40ms nói lên tất cả.

Muốn xem toàn bộ spec và các entry mới sắp thêm, theo dõi repo W3C long-animation-frames. Issue tracker ở đó là nơi phần invoker chi tiết cho Web Components đang được thảo luận.

Câu hỏi thường gặp

Điểm INP bao nhiêu được coi là tốt trong 2026?

≤200ms tại P75 là "Good"; 201–500ms là "Needs Improvement"; >500ms là "Poor". Google dùng P75 vì phản ánh trải nghiệm điển hình, không phải best-case. Để pass Core Web Vitals bạn phải giữ P75 INP dưới 200ms trên cả mobile và desktop.

Long Animation Frames API có chạy trên Safari và Firefox không?

Hiện tại (07/2026) LoAF chỉ ổn định trên Chromium (Chrome 123+, Edge, Opera, Samsung Internet). Firefox đánh giá là "worth prototyping" trong Interop 2025, còn Safari chưa có tuyên bố chính thức. Trên các trình duyệt khác bạn cần fallback về Long Tasks API.

Vì sao script LoAF của tôi không hiển thị sourceURL?

Chrome ẩn URL của script cross-origin không có CORS. Thêm crossorigin="anonymous" vào thẻ <script> và bật header Access-Control-Allow-Origin trên CDN đích. Với Chrome extension thì không thể sửa, URL sẽ mãi ẩn.

LoAF khác Total Blocking Time (TBT) trong Lighthouse thế nào?

TBT tính tổng thời gian block main thread trong lab (Lighthouse), dựa trên Long Tasks. LoAF là API runtime cho field data (RUM), đo mỗi frame >50ms kèm attribution. TBT giúp so sánh lab-to-lab; LoAF giúp debug user thực trong sản xuất. Hai công cụ bổ sung nhau chứ không thay thế.

Có nên đo LoAF trên mọi user hay chỉ mẫu?

API tự thân rẻ, nhưng chi phí thật nằm ở beacon và ingest. Với site >100k phiên/ngày tôi lấy mẫu 10% và cap 12 frame/session, đủ để top-5 offender lên biểu đồ heat trong 24h. Site nhỏ có thể để 100% với durationThreshold: 100.

Robin Chowdhury
Về Tác Giả Robin Chowdhury

Frontend performance architect at a large e-commerce site. Spends his days fighting third-party scripts.