Long Animation Frames API: 本番環境でのINPのデバッグ
インタラクションを破壊したスクリプトを特定するための本番環境向けプレイブック

何年もの間、「INPの原因は何か」という問いに対する答えは、多くの場合、呆然とした表情で迎えられていた。Long Tasks APIは、メインスレッドが50ms以上ブロックされたことを教えてくれた。しかし、どのスクリプトかは教えてくれなかった。どの行かも教えてくれなかった。私たちにあったのは、ms単位の数字だけだった。
Long Animation Frames APIはこれを解決する。Chrome 123以降のChromiumブラウザに搭載されており、フレームの完全な構造(合計期間、ブロック期間、レンダラーが開始された瞬間、スタイルとレイアウトが開始された瞬間、およびフレーム内で5msを超えて実行されたすべてのスクリプトの配列)を提供する。各スクリプトエントリには、ソースURL、関数名、ファイル内の文字位置、およびそれを呼び出したコールバックのタイプが含まれる。
これこそが、INPのデバッグに常に必要とされていたデータだ。この記事の残りの部分では、状況を悪化させることなく実際の環境でこれを使用する方法について説明する。
このページは、私たちのInteraction to Next Paint (INP)シリーズの一部である。以下のLoAFプレイブックは、私が指標を診断した後に実行するものである。INPを初めて使う場合は、input delay、processing time、presentation delayの3つのフェーズのガイド、およびより広範なINP問題の発見と修正のワークフローから始めてほしい。
なぜLong Tasksでは不十分だったのか
メインスレッド上の単一のタスクが50msを超えて実行されると、ロングタスクが発生する。これは明白なブロッカーを捕捉するのに役立つ。しかし、遅いインタラクションのほとんどは1つのロングタスクではないため、INPにとっては役に立たない。それらは中程度のタスクのシーケンスに長いレンダリングが加わったものである。
インタラクションは、ロングタスクを一度もトリガーすることなくINPに失敗する可能性がある。40msのイベントハンドラーの後に70msのスタイル再計算が続くと、合計で110msのプレゼンテーション遅延になる。Long Tasks APIは何も報告しない。ユーザーは遅延を感じる。
Long Tasksのもう一つの欠点は帰属(attribution)である。コンテナタイプ、コンテナ名、およびロングタスクが"self"、"same-origin-ancestor"、"same-origin-descendant"、"unknown"、またはいくつかのクロスオリジンバリアントのいずれであったかを示すnameフィールドを取得できる。実行されたスクリプトは取得できない。そのため、ロングタスクを捕捉したとしても、原因を見つけるためにはDevToolsを開いてインタラクションを再記録する必要がある。これはラボ環境では機能するが、再現できない実際のユーザーのINPでは機能しない。
LoAFは、これらの制限の両方を置き換える。単一のタスクではなく、フレーム全体をキャプチャする。そして、URLと関数名によって、どのスクリプトが実行されたかを正確に教えてくれる。
重要な7つのフィールド
LoAFエントリには、必要以上の数のフィールドがある。この7つは、私がインタラクションをデバッグする際に最初に読むものである。
durationは、フレームの合計の長さをミリ秒単位で表したものだ。これが50msを超えた場合にのみ、LoAFエントリが作成される。この数値だけでフレームが問題であるかどうかはわかるが、その理由はわからない。
blockingDurationはより有用だ。ロングタスクのしきい値を超えたフレームの部分を合計し、それぞれから50msを引く。duration: 320でblockingDuration: 0のフレームは、ほとんどがレンダリング作業である。duration: 320でblockingDuration: 270のフレームは、長いスクリプトである。前者はレンダリングの修正が必要で、後者はコードの変更が必要である。
renderStartは、ブラウザがフレームのレンダリング作業を開始したタイムスタンプである。startTimeとrenderStartの間はすべてスクリプトの実行である。それ以降はすべてスタイル、レイアウト、およびペイントである。ボトルネックがJavaScriptなのかレンダリングなのかを教えてくれるため、これはINPデバッグにおいて最も有用な単一の境界である。
styleAndLayoutStartは、レンダリングがアニメーションフレームのコールバックの実行から実際のスタイルの再計算とレイアウトに移行したタイムスタンプである。renderStartとstyleAndLayoutStartのギャップは、あなたのrequestAnimationFrameコールバックである。styleAndLayoutStartとフレームの終わりのギャップは、ブラウザ側のスタイルとレイアウト作業である。
firstUIEventTimestampは、フレーム中にユーザー入力が到着した時刻である。この値がゼロでない場合、フレームにはインタラクションが含まれている。これはweb-vitals.jsがLoAFエントリをINP測定値と関連付ける方法である。firstUIEventTimestampがないということは、フレームは遅いがユーザーはそれを待っていなかったことを意味する。
scriptsは、帰属のために読む配列である。各エントリは、少なくとも5ms実行されたスクリプトである。これにはsourceURL、sourceFunctionName、invoker、invokerType、duration、およびforcedStyleAndLayoutDurationが含まれる。最後のものは重要だ。スクリプトが同期レイアウトをトリガーしたかどうかを教えてくれる。これは、私が監査で目にする処理時間のボトルネックのほとんどの根本原因である。
各スクリプトのinvokerTypeフィールドは、それがどのように呼び出されたかを教えてくれる。完全なリストは、event-listener(クリック、スクロール、キーダウン)、user-callback(setTimeout、setInterval、requestAnimationFrame)、resolve-promiseとreject-promise(then/catchハンドラー)、classic-script、およびmodule-scriptである。INPにおいて、最初に読むべきものは処理時間のためのevent-listener、そしてinput delayのためのuser-callback、classic-script、またはmodule-scriptである。Promiseハンドラーがリストのトップになることはめったにない。
これらのフィールドを3つのINPフェーズにマッピングすると、状況が明確になる。input delayは、firstUIEventTimestampの前に実行されているスクリプトとして現れる。processing timeは、UIイベントの後に実行されているevent-listenerスクリプトである。presentation delayは、renderStartからフレームの終わりまでのすべてである。
web-vitals.jsを使用した本番環境でのLoAFのキャプチャ
LoAFオブザーバーをゼロから構築する必要はない。web-vitals.jsのattributionビルドがそれを代行し、LoAFエントリを実際のINP測定値と関連付ける。これが、私がクライアントのサイトにインストールするバージョンである。
import { onINP } from 'web-vitals/attribution';
onINP((metric) => {
const a = metric.attribution;
const payload = {
value: metric.value,
rating: metric.rating,
url: location.pathname,
loadState: a.loadState,
interactionTarget: a.interactionTarget,
interactionType: a.interactionType,
inputDelay: a.inputDelay,
processingDuration: a.processingDuration,
presentationDelay: a.presentationDelay,
longestScriptDuration: a.longestScript?.entry?.duration,
longestScriptInvoker: a.longestScript?.entry?.invoker,
longestScriptSource: a.longestScript?.entry?.sourceURL,
longestScriptSubpart: a.longestScript?.subpart,
longestScriptIntersecting: a.longestScript?.intersectingDuration,
};
navigator.sendBeacon('/rum', JSON.stringify(payload));
}); そのpayloadは十分に小さいため、サンプリングせずに送信できる。3つのフィールドが診断作業の大部分を行う。longestScriptInvoker、longestScriptSource、およびlongestScriptSubpartである。最初の2つは何が実行されたかを教えてくれる。3つ目は、それがどのINPフェーズに分類されたかを教えてくれる。
最長のスクリプトだけでなく完全な帰属情報が必要な場合は、a.longAnimationFrameEntriesも送信するとよい。1つのINP測定値には複数のLoAFが含まれる可能性があり、重いLoAFには10個以上のスクリプトエントリが含まれる可能性があることに注意してほしい。すべてのページビューで完全な配列を送信するのはコストがかかる。私は通常、metric.rating !== 'good'の場合にのみ、完全な配列をビーコンで送信している。
重要な詳細が1つある。LoAFとINP候補のインタラクションが重ならない場合、またはブラウザがLoAFをまったくサポートしていない場合、longAnimationFrameEntries配列は空になる。空の配列を問題ないシグナルとして扱う前に、常に機能検出を行ってほしい。
const loafSupported = PerformanceObserver.supportedEntryTypes?.includes('long-animation-frame'); プライバシーに関する考慮事項。スクリプトのsourceURL値には、クエリパラメータやフルパスが含まれる場合がある。クライアントワークでは、RUMに送信する前に、オリジンとパス名以外のすべてを削除している。ハッシュ化されたバンドルファイル名は、デプロイメント内のデバッグには役立つが、デプロイメントをまたいだグループ化には役に立たないため、ハッシュセグメントも削除している。
925,000のURLからの帰属パターン
CoreDashは、監視しているサイトのすべてのINP測定値についてLoAFの帰属をキャプチャする。925,000のURLのデータセット全体で、一握りの同じスクリプトカテゴリが、ブロックするLoAFの主な原因として何度も繰り返し現れている。
Eコマースサイトでは、上位のブロッキングオリジンは一貫している。同期のカスタムHTMLタグを実行しているタグマネージャーが1位だ。遅延バインディングのスクリプトインジェクションを行う同意管理プラットフォームが2位。広告と入札スクリプトのオーケストレーションが3位。4位は、大規模なクライアントサイドのハイドレーションを行う商品推奨ウィジェット。5位は、すべてのインタラクションを計装するセッションリプレイスクリプトである。
クライアントを驚かせるのは、forcedStyleAndLayoutDurationの割合である。典型的な貧弱なINPフレームでは、最長のスクリプトの実行時間の30〜60%が、スクリプトが同期レイアウトをトリガーしている時間である。スクリプトが遅いのは、JavaScriptが重いからではない。DOMへの書き込みも行うループ内でJavaScriptがレイアウト(offsetHeight、getBoundingClientRect)を読み取るため、遅いのだ。レイアウトスラッシングである。開発者が15年間書き続けてきたのと同じ問題であり、私が監査するサイトの処理時間の最大の要因であることに変わりはない。
私たちのデータセット全体で、event-listenerインボーカーは、INPと交差する最長のスクリプトの約半分を占めている。user-callback(setTimeout、setInterval、requestAnimationFrame)は約4分の1を占める。classic-scriptのトップレベルの実行が残りの大部分を占める。Promiseリゾルバーが最長のスクリプトになることはめったになく、これは「非同期コードが原因である」というINPの一般的な仮定と矛盾している。
私が最もよく目にする5つのLoAFの形状
十分な監査を行うと、LoAFエントリは見慣れたものになり始める。失敗しているインタラクションのほとんどは、5つの形状のいずれかに一致する。それぞれに異なる修正方法がある。
サードパーティのイベントリスナー
最長のスクリプトはinvokerType: "event-listener"であり、sourceURLはサードパーティのオリジンからのものである。スクリプトの実行時間はフレームの大部分を占め、forcedStyleAndLayoutDurationは短い。これはベンダーのタグがあなたのクリックをリッスンし、ハンドラー内で過剰な作業を行っている状態だ。解決策は「タグを削除する」ことではない。解決策は、タグが行う作業を遅延させることだ。特にdataLayerのプッシュには、アナリティクスを壊すことなく20〜100msを取り戻すパターンがあり、それについてはINPを最適化するためのdataLayerイベントのスケジューリングで取り上げている。
自前のハンドラー内でのレイアウトスラッシング
最長のスクリプトはinvokerType: "event-listener"であり、sourceURLは自前のバンドルであり、forcedStyleAndLayoutDurationがスクリプト実行時間の30%を超えている。スクリプトはループ内でレイアウトを読み取っている。解決策は、書き込みの前に読み取りをバッチ処理するか、作業をrequestAnimationFrameに移動してキャッシュされた値を使用することだ。
私はEコマースサイトの商品フィルターページでこれを最もよく目にする。フィルターのハンドラーは表示されている商品数を更新し、次に結果リストの新しい高さを読み取って固定フィルターボタンの位置を再調整し、次にその高さに基づいてCSSカスタムプロパティを更新し、そして再び高さを読み取る。1つのハンドラー内に4つの強制レイアウトがある。LoAFは、その80〜150msのforcedStyleAndLayoutDurationを単一のスクリプトエントリに配置するため、修正方法は明らかだ。一度読み取り、一度書き込み、終了する。
最初のインタラクションでのハイドレーションカスケード
loadStateがまだ"loading"または"dom-interactive"である間にLoAFが発生する。firstUIEventTimestampは、scriptsにフレームワークのハイドレーションコールバックが含まれるフレーム内に位置する。ユーザーはページがハイドレーションされる前にクリックしたのだ。解決策は、ハイドレーションを高速化することではない(それが役立つとしてもだ)。解決策は、インタラクティブな要素がハイドレーションなしで機能するようにすることだ。ネイティブのリンクとフォーム送信、ネイティブのディスクロージャーウィジェット、ネイティブのダイアログである。プログレッシブエンハンスメントだ。
重いDOMによるプレゼンテーション遅延
この問題はデータ上では異なって見える。blockingDurationは低く、scripts配列は短い。しかし、styleAndLayoutStartとフレームの終わりのギャップは100msを超えている。JavaScriptの修正は役に立たない。レンダラーは、何千ものノードにわたるスタイルの再計算で息詰まっている。画面外のセクションにはcontent-visibility: autoを使用し、重いコンポーネントにはcontain: layout styleを使用し、インタラクションごとに再計算しなければならないスタイルスコープのサイズを縮小してほしい。
rAFの嵐
複数のLoAFが連鎖し、それぞれにWindow.requestAnimationFrameによって呼び出されたuser-callbackスクリプトがある。ページは、ユーザーと競合するJavaScriptアニメーションを実行している。JavaScript駆動のスクロール動作が最も一般的なバージョンである。誰も実装したことを覚えていない1つのスムーズスクロールのポリフィルのせいで、すべてのページのすべてのクリックに200msのプレゼンテーション遅延を追加しているサイトを見たことがある。スクロールのケースについてはJavaScriptスクロールを廃止してINPを改善するで取り上げた。あらゆるJavaScriptアニメーションについても同じロジックだ。CSSまたはWeb Animations APIに切り替えれば、LoAFは消え去る。
フィールドでのLoAFのpayloadの見た目
ここに、チェックアウトページの監査から得られた実際のLoAFエントリを示す。URLは匿名化しているが、構造とタイミングはそのまま残っている。
{
"name": "long-animation-frame",
"entryType": "long-animation-frame",
"startTime": 5902,
"duration": 320,
"blockingDuration": 268,
"renderStart": 6170,
"styleAndLayoutStart": 6172,
"firstUIEventTimestamp": 5913,
"scripts": [
{
"name": "script",
"entryType": "script",
"invoker": "BUTTON#checkout-submit.onclick",
"invokerType": "event-listener",
"sourceURL": "https://cdn.example.com/app.HASH.js",
"sourceFunctionName": "handleCheckoutSubmit",
"duration": 248,
"forcedStyleAndLayoutDuration": 142,
"executionStart": 5914,
"startTime": 5914
}
]
} 次のように読んでほしい。フレームはページ読み込みから5902ms後に開始された。5913msにユーザーがチェックアウトの送信ボタンをクリックした。クリックハンドラーは5914msに開始され、248ms実行された。その248msのうち、142msは強制的な同期レイアウトだった。レンダラーは、クリックから4分の1秒以上経過した6170msまで開始できなかった。このインタラクションのINPは約270msであり、「改善が必要」のゾーンにある。
修正方法は「handleCheckoutSubmitを高速化する」ことではない。修正方法は「handleCheckoutSubmit内でレイアウトを強制するのをやめる」ことだ。248msのうちの142msはJavaScriptではない。JavaScriptの書き込みが無効化したレイアウト値を読み取っているのだ。これが得られた実際の案件では、書き込みループの前に一度読み取り値をキャッシュすることで、スクリプトの実行時間が248msから110msに短縮された。チェックアウトページのINPはp75で270msから200ms未満に改善した。このページは合格した。
これは、いかなるラボツールにも表示されない種類の修正である。Lighthouseはページと対話しないため、forcedStyleAndLayoutDurationについて教えてくれない。フィールドでのLoAFが、それを確認できる唯一の方法である。
LoAFデータを利用したAIエージェントの駆動
AI支援のデバッグにおいてLoAFが重要である理由は、それがエージェントに推論するための具体的な何かを与えるからだ。フィールドデータを持たないエージェントは、コードからINPの原因を推測することしかできない。実際のユーザーセッションからのLoAFエントリを持つエージェントは、スクリプト、関数、およびフェーズを特定できる。診断はもはや推測ではなくなる。
私がClaude Codeで使用しているワークフローは次のとおりだ。MCPサーバー経由でCoreDashから最悪のINPページをプルし、そのURLの最も遅いインタラクションのLoAFエントリを添付し、各エントリが上記の5つの形状のどれに一致するかを特定するようエージェントに依頼する。エージェントは各LoAFを分類し、コードベース内の関数を指摘し、修正案を提示する。私が修正案をレビューし、適用する。そしてCoreDashがp75のINPが変化するかどうかを測定する。
これが機能するのは、LoAFデータが構造化されており、かつ小さいためである。単一のLoAF JSONのpayloadは、エージェントのコンテキストウィンドウのほんの一部に収まる。1つのページに関するいくつかの代表的なLoAFがあれば、修正を進めるには十分である。より広範なパターンについては、AIエージェントによるINPの修正:ラボツールでは測定できない指標およびAIエージェントとCore Web Vitals:フィールドデータがすべてを変える理由で書いた。
クロスブラウザの現実
LoAFはChromium専用である。Safariはこれを実装していない。Firefoxも実装していない。INP自体が現在Safari 26.2とFirefox 144で出荷されているため、測定のギャップが生じている。INPのRUMデータはクロスブラウザだが、帰属データはChromeとEdgeのものだ。
このために、クライアントがLoAFの採用をためらっているのを目にすることがある。彼らはSafariがそれを出荷するまで待ちたがっているのだ。それは誤った判断である。ほとんどのパフォーマンスバグが最初に再現され、ほとんどのパフォーマンスの修正が最初に検証されるのはChromeだ。問題の形状(長いイベントハンドラー、レイアウトスラッシング、ハイドレーションカスケード)はブラウザ間で変わらない。診断のレンズが異なるだけだ。ChromiumのLoAFデータに基づいて出荷した修正は、SafariやFirefoxユーザーのINPも改善する。
今日Safariで何らかの帰属情報が必要な場合は、EventTimingを使用することになる。これはどのイベントが遅かったかを教えてくれるが、どのスクリプトかは教えてくれない。どこを見るべきかを知るには十分だが、何を修正すべきかはわからない。Safariの帰属情報については、Safari Webインスペクタでのラボプロファイリングが現実的なfallbackとなる。
LoAFがまだ教えてくれないこと
いつか足元をすくわれることになるため、3つの死角については知っておく価値がある。
1つ目は、クロスオリジンのiframeである。LoAFは、ドキュメントのオリジンを共有するウィンドウのメインスレッドで実行されているスクリプトのみを帰属させる。サードパーティのiframe内のブロッキングスクリプトは、不透明な帰属として表示される。つまり、長いフレームと空のscripts配列である。修正方法は、サードパーティのiframeのパフォーマンス問題と同じだ。iframeの読み込みを遅延させ、明示的な寸法を与え、スクロールせずに見える範囲よりも下にある場合はloading="lazy"を使用する。
ワーカーが2つ目の死角だ。LoAFはメインスレッドのAPIであるため、WebワーカーはLoAFエントリに表示されない。ワーカーが重いメッセージをメインスレッドに送り返しているためにINPが悪化している場合、LoAFはメインスレッド上のメッセージハンドラーを表示するが、それをトリガーしたワーカーの作業は表示しない。ワーカー内のパフォーマンスマークを使用して、手動で相互参照する必要がある。
そして最後に、GPUコンポジターの遅延である。LoAFエントリの終わりは、レンダラーがメインスレッドでの作業を完了した時だ。実際に画面上にピクセルが表示されるのは、コンポジターとGPUでの処理後であり、もっと遅くなる。ローエンドのAndroidデバイスでは、コンポジターの遅延によって、LoAFには見えない30〜100msが追加される可能性がある。LoAFエントリのpresentationTimeフィールド(現在は実験的なPaintTimingMixinフラグの後ろにある)はこれに対処するためのものだが、Chromeのバージョン間ではまだ安定していない。
これらのどれも致命的ではない。これらは単に、LoAFがすべてを語るわけではないということを意味している。実際のクライアントサイトのINP問題の80%にとっては、これで十分である。
どこから始めるべきか
これまでLoAFデータを見たことがない場合は、ラボではなくフィールドから始めてほしい。web-vitals.jsのattributionビルドをインストールし、INP測定値ごとに最長のスクリプトをビーコンで送信し、1週間のトラフィック全体で上位10個のlongestScriptSource値を見てほしい。Lighthouseのレポートが何を言おうと、トップに位置するものがあなたの最初の修正対象である。
ラボのビューが必要な場合、Chrome DevToolsのPerformanceパネルはネイティブではLoAFを表示しないが、データはPerformanceObserver経由で利用できる。devtoolsの詳細フィールドと一緒にperformance.measureを使用するカスタムトラックのアプローチは、パネル内にLoAFエントリを表面化させる。webperf-snippetsプロジェクトには、インタラクションを含むLoAFエントリの概要テーブルを出力するコンソールスニペットがあり、特定のページをデバッグするときに私が最初に実行するのは通常これである。
INPシリーズの他の記事を探す
このLoAFプレイブックは、より大きなワークフローの中に収まる。各フェーズやステップについてさらに深く掘り下げるには:
- INP問題の発見と修正: RUMデータから遅いインタラクションまでの診断フロー。
- Input delay: イベントハンドラーが開始される前に実行されるスクリプト。
- Processing time: イベントハンドラー自体。
- Presentation delay: ハンドラーが戻った後のレンダリング作業。
- INPハブ: 指標の完全なガイド。
ソース: Chrome for Developers, Long Animation Frames API, MDN, Long animation frame timing, W3C Long Animation Frames specification, GoogleChrome/web-vitalsライブラリ。
Long Animation Frames APIに関する質問と回答
ブラウザのサポートとLoAFが置き換えるもの
Long Animation Frames APIはすべてのブラウザでサポートされていますか?
いいえ。LoAFはChromium専用です。Chrome、Edge、Opera、Braveがこれを備えています。SafariとFirefoxにはありません。依存する前に、常にPerformanceObserver.supportedEntryTypes?.includes('long-animation-frame')で機能検出を行ってください。
LoAFはLong Tasks APIを置き換えますか?
INPのデバッグにおいては、はい。Long Tasksはスクリプトの帰属なしにメインスレッドのブロック時間を提供します。LoAFは同じブロッキングシグナルに加えて、実際に実行されたスクリプトを提供します。新しいINP作業にLong Tasksを使用する理由はありません。Total Blocking Timeは最終的にLoAFのblockingDurationの観点から再定義される可能性があります。
LoAFデータの読み取り
LoAFエントリを特定のINP測定値と関連付けるにはどうすればよいですか?
web-vitals.jsのattributionビルドは、attribution.longAnimationFrameEntriesを通じてこれを代行します。タイミングウィンドウがINP候補のインタラクションと重なるLoAFエントリを見つけます。独自のオブザーバーを作成する場合は、entry.startTimeおよびentry.startTime + entry.durationをインタラクション時間と比較し、ウィンドウが交差するLoAFを含めてください。
forcedStyleAndLayoutDurationは実際には何を意味しますか?
これは、スクリプトが同期レイアウトをトリガーするのに費やした時間です。JavaScriptがDOMの変更後に最新のレイアウトを必要とするoffsetHeight、getBoundingClientRect、またはその他のプロパティを読み取る場合、ブラウザはスクリプト内で同期的にレイアウト作業を行わなければなりません。その作業はスクリプトに帰属します。高いforcedStyleAndLayoutDurationは、ほぼ間違いなくレイアウトスラッシングです。
一部のLoAFエントリでscripts配列が空になるのはなぜですか?
理由は3つあります。第1に、エントリには少なくとも5ms実行されたスクリプトのみが含まれます。そのしきい値を下回る短いスクリプトで満たされたフレームには、帰属情報が表示されません。第2に、LoAFは同一オリジンのメインスレッドしか見ないため、クロスオリジンのiframe、Webワーカー、サービスワーカー、またはブラウザ拡張機能内で実行されているスクリプトは帰属しません。第3に、フレームにスクリプトの作業がまったくなく、スタイルとレイアウトのみがある場合があります。これはプレゼンテーション遅延のフレームです。
INPを超えて
LoAFはINPだけでなく、LCPにも役立ちますか?
はい、間接的に役立ちます。LCP要素がレンダリングされる前に実行されるLoAFは、LCPのレンダリングを遅延させる長いスクリプトです。終了時刻がLCPタイムスタンプの前であるLoAFをフィルタリングすると、そこにある最長のスクリプトがLCPレンダリング遅延の最大の要因になります。同じ帰属データを異なる指標に適用するのです。