【実務・中級編】 INP(Interaction to Next Paint)の仕組み – Webブラウザの仕組み実践ガイド

やあ、お疲れ様。
最近、社内のモニタリングツールやGoogle Search Consoleで「INP(Interaction to Next Paint)」の数値が悪化して頭を抱えていないかい?

「Core Web Vitalsの新しい主役」なんて言われて久しいけれど、ぶっちゃけ、これまでのFID(First Input Delay)とは比べ物にならないくらいシビアな指標だ。FIDは『最初の入力の遅延』しか測っていなかったのに対し、INPはページが寿命を迎えるまでの全インタラクションのワーストケースを評価しやがる。つまり、ユーザーがボタンを連打したり、スクロールしまくったりしたときの「もたつき」がすべてスコアに直結するんだ。

今回は、このINPの正体をブラウザの裏側の動きから徹底的に解剖し、現場のコードをどう改善すればいいのか、俺たちの知見を総動員して解説していこう。

—

1. INPとは何か? ブラウザの裏側で何が起きているのか

まずは敵を知ることから始めよう。INPは、ユーザーがアクション(クリック、タップ、キーボード入力)を起こしてから、ブラウザが「次のフレームを描画(Next Paint)し終えるまで」の合計時間を指す。

この一連の流れは、ブラウザのメインスレッド上で次のように分解できる。

1. 入力遅延(Input Delay): ユーザーが操作した瞬間から、イベントハンドラ(`click`リスナーなど)の実行が始まるまでの待ち時間。メインスレッドがJavaScriptの重い処理やガベージコレクション(GC)で埋まっていると、ここで盛大に待たされる。
2. 処理時間(Processing Time): イベントハンドラ(JSのコード)が実際に実行されている時間。ここでDOMをいじりまくったり、重いループを回したりすると、当然メインスレッドが占有される。
3. プレゼンテーション遅延(Presentation Delay): イベントハンドラが終わった後、ブラウザがスタイル計算(Recalculate Style)、レイアウト(Layout)、ペイント(Paint)、そしてコンポジット(Composite)を行い、画面にピクセルを描き出すまでの時間。

INPが悪いというのは、この「1 + 2 + 3」のトータル時間が200ミリ秒を超えている状態を指す。200ms超えを連発しているサイトは、ユーザーの体感として「なんかこのサイト、クリックしても反応しねえな、クソが」と感じさせてしまっているわけだ。

—

2. なぜINPは悪化するのか?(フロントエンドの悪癖)

現場でよく見かけるINP悪化の主犯格は、だいたい決まっている。

  • メインスレッドの過密スケジュール: 1つのタスクが50msを超えるような「Long Task」が走っている最中にユーザーがクリックすると、ブラウザはそれをすぐには処理できない。
  • 不必要な大域的DOMの書き換え: クリックイベントの中で、何百個ものDOM要素をごっそり書き換えている。ブラウザは泣きながらスタイル計算とレイアウトをやり直す羽目になる。
  • JavaScriptのやりすぎ: フレームワークのハイドレーション直後や、ステート更新に伴う不要な再レンダリングの嵐。

特にReactやVueなどのSPA(Single Page Application)を作っていると、「状態が変わったら全部よしなにやってくれる」という抽象化の裏で、ブラウザのメインスレッドが死にかけていることに気づきにくい。

—

3. 実践:INPを改善するためのコードパターン

百聞は一見にしかずだ。ここからは、現場で今すぐ使える具体的な処方箋を見ていこう。

処方箋A: 重い処理を「yield」させてメインスレッドを解放する

もし、クリック時にどうしても重い同期処理を実行しなければならない場合、処理をチャンク(分割)して、ブラウザに「息継ぎ(レンダリングの機会)」を与えてやる必要がある。

ここで使えるのが、モダンブラウザの`scheduler.postTask()`や、お馴染みの`setTimeout(…, 0)`を使ったYielding(処理の譲渡)だ。

/

  • メインスレッドをブロックせずに、重い配列の処理を行うサンプル
  • @param {Array} items 処理対象の大規模な配列

/
async function processLargeList(items) {
const results = [];

for (const item of items) {
// 1件あたりの重い処理をシミュレート
results.push(heavyComputation(item));

// ブラウザが描画やユーザー入力を処理できるように、定期的にメインスレッドを解放する
// scheduler APIが使えるならそっちを優先(優先度を低く設定できる)
if (shouldYieldToBrowser()) {
await yieldToMainThread();
}
}

return results;
}

// ブラウザのスケジュールAPIを使ってメインスレッドに制御を戻すヘルパー
function yieldToMainThread() {
if (‘scheduler’ in window && ‘postTask’ in window.scheduler) {
return window.scheduler.postTask(() => {}, { priority: ‘background’ });
}
// フォールバック
return new Promise(resolve => setTimeout(resolve, 0));
}

// 簡易的な判定(実際には前回のYieldからの経過時間などを測る)
let lastYieldTime = performance.now();
function shouldYieldToBrowser() {
const now = performance.now();
// 50ms以上メインスレッドを占有していたら強制的に息継ぎさせる
if (now – lastYieldTime > 50) {
lastYieldTime = now;
return true;
}
return false;
}

処方箋B: DOM更新の「トランポリン」を避ける(Layout Thrashingの防止)

イベントハンドラ内で「DOMの読み取り」と「DOMの書き込み」を交互に行うと、ブラウザは強制同期レイアウト(Forced Synchronous Layout)を引き起こし、プレゼンテーション遅延が跳ね上がる。

悪い例の代表格:

// ❌ 最悪のパターン:読んでは書き、読んでは書きを繰り返す
elements.forEach(el => {
const height = el.offsetHeight; // 読込(レイアウト強制発生)
el.style.height = (height + 10) + ‘px’; // 書込
});

これを、「最初にまとめて読み、後でまとめて書く(Batching)」形にリファクタリングするだけで、INPは劇的に改善する。

// ⭕️ ベストプラクティス:読み込みと書き込みを完全に分離する

// 1. 必要な数値を最初にすべてメモリ上に読み込む
const heights = elements.map(el => el.offsetHeight);

// 2. その後、一気に書き込みを行う(ブラウザはスタイル計算を1回にまとめられる)
requestAnimationFrame(() => {
elements.forEach((el, index) => {
el.style.height = (heights[index] + 10) + ‘px’;
});
});

この `requestAnimationFrame` を使うアプローチは、ブラウザが次に画面を塗り替えるタイミング(Paint)の直前にコードを挟み込めるため、無駄なレイアウト計算を綺麗に回避できるんだ。

—

4. 現場での計測とモニタリングの極意

ローカル環境でいくら「サクサク動くぜ」と思っていても、ユーザーの持っているスマホが数年前のミドルレンジ端末だったら、メインスレッドの処理速度は俺たちのPCの数分の一になる。

だからこそ、実測値(RUM: Real User Monitoring)を取る必要がある。
コードレベルで簡単にINPを計測したいなら、Googleが提供している `web-vitals` ライブラリを導入するのが一番手っ取り早い。

import {onINP} from ‘web-vitals’;

// ユーザーがページを離脱する際やバックグラウンドに移る際に、INPのスコアをアナリティクスに送信する
onINP((metric) => {
console.log(‘現在のINP値:’, metric.value);
console.log(‘最も時間がかかった要素:’, metric.entries[0]?.target);

// 例: Google Analytics 4 に送信
// gtag(‘event’, metric.name, {
// value: Math.round(metric.name === ‘CLS’ ? metric.value 1000 : metric.value),
// event_category: ‘Web Vitals’,
// event_label: metric.id,
// non_interaction: true,
// });
}, {reportAllChanges: true});

このログに出てくる `metric.entries[0]?.target` を見てほしい。「どのボタン、どの要素をクリックしたときの操作が原因で遅延したのか」がピンポイントでわかるようになってる。これを使えば、「どの画面のどのインタラクションが遅いか」を勘に頼らず、データに基づいて潰していくことができる。

—

まとめ

INPの最適化は、派手な新機能の追加でもなければ、最新のビルドツールを入れるだけで解決する魔法の杖でもない。

「ブラウザのメインスレッドをいかに占有せず、ユーザーの入力に対して素早く視覚的なフィードバックを返すか」という、フロントエンドの極めて泥臭い基礎体力の勝負だ。

  • 重い処理は小さく分割して `yield` する。
  • DOMの読み書きは分離して、不要なレイアウトスラッシングを避ける。
  • `web-vitals` で実測値を監視し、遅い箇所を特定して地道に潰す。

この基本をチーム全体で徹底できれば、ユーザー体験は必ず見違えるように軽快になるはずだ。さっそく、今日のコードレビューから「このイベントハンドラ、メインスレッドを独占してないか?」という視点を取り入れていこうぜ。

コメント

タイトルとURLをコピーしました