【テクニカル・上級編】 INP(Interaction to Next Paint)の仕組み – Webブラウザの仕組み実践ガイド

INP(Interaction to Next Paint)の深層:メインスレッドの支配者から降りるためのアーキテクチャ設計

ブラウザのレンダリングパイプラインを愛してやまないエンジニア諸君。Core Web Vitalsの新たな主役、そして私たちのアプリケーションの「体感速度」を残酷なまでに暴き出す指標、INP(Interaction to Next Paint)と日々格闘していることだろう。

かつて持て囃されたFID(First Input Delay)は、ユーザーの最初のインタラクションの「入力の遅延(最初のタスクが始まるまで)」しか測っていなかった。しかし、現実のWebアプリは違う。クリックした後に数百年分の重いJavaScriptが走り、DOMが書き換わり、スタイルが再計算され、レイアウトが発生し、ペイントされてようやく画面が動く。この「操作から次の描画まで(Input to Next Paint)」の全行程を評価するINPこそが、現代のWebアプリケーションの真のレスポンスポテンシャルを映し出す鏡なのだ。

今回は、BlinkやWebKitといったブラウザエンジンの内部挙動、メインスレッドのメモリ効率、そして非同期タスクの競合という泥臭い現実を踏まえながら、INPを極限まで最適化するためのアーキテクチャ論を語り尽くす。

—

1. INPのライフサイクルとブラウザエンジンの内部挙動

INPを改善するためには、ユーザーが画面をタップあるいはクリックしてから、次のフレームが画面に描画されるまでの間に、ブラウザのメインスレッドで何が起きているのかを解剖学的に理解する必要がある。

INPの遅延(Latency)は、厳密には以下の3つのフェーズに分解できる。

1. 入力遅延(Input Delay): ユーザーの操作(`pointerdown`, `click`, `keydown` 等)がOSからブラウザに検知されてから、イベントハンドラーの実行が開始されるまでの時間。メインスレッドが他の長大なタスク(例えばハイドレーションや大きなJSONのパース)で埋まっていると、ここで大きく跳ね上がる。
2. 処理時間(Processing Duration): イベントリスナー(コールバック関数)が実行され、その中でDOMの変更や状態更新がトリガーされるまでの時間。ここで無駄な同期処理や、メモリを圧迫するオブジェクト生成が行われていると地獄を見る。
3. プレゼンテーション遅延(Presentation Delay): DOM/CSSOMのツリー構築、スタイル計算(Recalculate Style)、レイアウト(Layout)、ペイント(Paint)、そしてコンポジット(Composite)を経て、次のフレームが画面に描画されるまでの時間。

特に見落とされがちなのが「3. プレゼンテーション遅延」だ。JavaScriptの実行が終わっても、DOMの変更が多すぎるとレイアウトスラッシング(強制同期レイアウト)を引き起こし、スタイル計算とレイアウトフェーズだけで数フレーム分の時間を溶かすことになる。

—

2. メモリ効率とガベージコレクション(GC)の悪夢

DOMの変更や複雑なコンポーネントの再描画を伴うインタラクションにおいて、最大の隠れた敵はJavaScriptエンジン(V8など)のガベージコレクション(GC)だ。

インタラクションの最中に大量のテンポラリなオブジェクトを生成・破棄すると、マイナーGC(Scavenger)やメジャーGC(Mark-Sweep-Compact)が誘発される。GCが走っている間、JavaScriptの実行だけでなく、メインスレッドの処理そのものが一時停止(Stop-the-World)する。

悪質なメモリパターンの例

// 【アンチパターン】インタラクションの最中に大量のオブジェクトとDOMを生成する
element.addEventListener(‘click’, (e) => {
const items = heavyData.map(d => {
// 毎回不要なオブジェクトとラッパーを生成
return { id: d.id, computed: expensiveCalculation(d.value) };
});

// DOMを一気に書き換える(レイアウトとGCの二重苦)
container.innerHTML = items.map(item => `

${item.computed}

`).join(”);
});

このコードの何がヤバいかというと、イベントハンドラーの実行時間そのものが長い上に、実行直後にV8のヒープメモリが圧迫され、次の描画(Next Paint)の直前や最中にGCが割り込んでくる点だ。結果として、プレゼンテーション遅延が跳ね上がり、INPのスコアは悪化する。

—

3. 非同期の競合とスケジューリング戦略

INPを最適化する基本方針は、「メインスレッドを常に空けておくこと」、そして「タスクを細かく分割(Chunking)すること」に尽きる。しかし、単に `setTimeout(…, 0)` を使えばいいというわけではない。

ブラウザのタスクキューには優先順位がある。マイクロタスク(`Promise` や `queueMicroTask`)は、現在のタスクが終わった直後にメインスレッドを明け渡すことなく実行されるため、レンダリングをブロックする原因になり得る。

真にレスポンシブなアプリケーションを作るには、ブラウザの描画パイプラインの隙間を縫うようにタスクをスケジュールする必要がある。ここで現行の標準APIである `requestIdleCallback` や、よりモダンな `scheduler.postTask()` を駆使したスケジューリングの知見が問われる。

実践:`scheduler.yield()` を使ったメインスレッドの解放

現代の高度なWebアプリで私たちが取るべきアプローチは、長大な処理の途中で意図的に処理を中断し、ブラウザに制御を返すことだ。これによって、ユーザーからの入力(INPのトリガー)が割り込む隙を与えることができる。

以下に、大規模なデータ処理やDOM更新を非同期に分割し、INPを悪化させないための実用的なユーティリティコードを示す。

/

  • メインスレッドをブロックしないための非同期チャンク処理ユーティリティ
  • 読者はそのままエディタに貼り付けて動作確認できます。

/
async function processChunksInference(dataItems, processCallback, onProgress) {
const results = [];

for (let i = 0; i < dataItems.length; i++) { // 各アイテムに対する重い処理を実行 results.push(processCallback(dataItems[i])); // 50アイテムごと、あるいはブラウザの負荷状況に応じてメインスレッドを解放 if (i % 50 === 0) { onProgress(i / dataItems.length); // ブラウザがネイティブに提供する Scheduler API があれば優先利用 if ('scheduler' in window && 'yield' in window.scheduler) { await window.scheduler.yield(); } else { // フォールバック:マクロタスクを挟んでレンダリングや入力を処理させる await new Promise(resolve => setTimeout(resolve, 0));
}
}
}

return results;
}

// 使用例
const interactiveButton = document.querySelector(‘#process-btn’);
interactiveButton.addEventListener(‘click’, async (e) => {
// 1. まず即座にUIをビジュアルフィードバック(ローディング表示など)に切り替える
e.target.classList.add(‘is-loading’);

const massiveDataset = Array.from({ length: 10000 }, (_, index) => index);

// 2. メインスレッドを窒息させないように分割処理を実行
try {
const finalResult = await processChunksInference(
massiveDataset,
(item) => item 2, // 重い計算のシミュレーション
(progress) => {
console.log(`処理進捗: ${Math.round(progress 100)}%`);
}
);
console.log(‘全処理完了:’, finalResult.length);
} finally {
e.target.classList.remove(‘is-loading’);
}
});

この実装の肝は、「ユーザーがボタンを押した直後に、最小限の描画変更(ローディングクラスの付与など)を即座にNext Paintさせ、その後の重い処理を細かく分割している」点にある。これにより、Input DelayとProcessing Durationの両方を劇的に圧縮できる。

—

4. 重大なバグの回避策:レイアウトスラッシングの根絶

INPを悪化させる犯人の中で、最もエンジニアの頭を悩ませるのがレイアウトスラッシング(Layout Thrashing)だ。JavaScriptでDOMの「読み取り」と「書き込み」を交互に繰り返すと、ブラウザはスタイル計算とレイアウトを何度も強制的に実行させられる。

バグを回避するためのアーキテクチャ

ブラウザのレンダリングパイプラインは、次のように効率化されている。

  • 読み取り(Read):`element.offsetWidth` や `getBoundingClientRect()` など
  • 書き込み(Write):`element.style.width = …` やクラスの付け替えなど

これらを同一フレーム内で混在させると、ブラウザはキャッシュを無効化し、同期的にレイアウトを再計算する。これを防ぐには、「読み取りを最初に一括して行い、その後に書き込みをまとめて行う(Read-then-Writeの原則)」を徹底することだ。

// 【改善版】レイアウトスラッシングを避ける一括処理パターン
function optimizedDOMUpdates(elements) {
// 1. フェーズ1: 必要なレイアウト情報をすべて「読み取る」(この時点では書き込みをしない)
const measurements = elements.map(el => {
return {
el,
width: el.getBoundingClientRect().width
};
});

// 2. フェーズ2: 読み取った情報をもとに、まとめて「書き込む」
requestAnimationFrame(() => {
measurements.forEach(item => {
// スタイルやサイズの変更をここで一気に適用
item.el.style.width = `${item.width 1.1}px`;
});
});
}

このアプローチを取ることで、ブラウザのスタイル計算およびレイアウトフェーズの無駄な重複実行を防ぎ、プレゼンテーション遅延を最小限に抑えることができる。

—

5. まとめ:真に堅牢なWebアプリケーションへ向けて

INPの最適化は、単なる「スコアのためのテクニック」ではない。それは、ユーザーが触れた瞬間に吸い付くような滑らかさを提供するための、フロントエンド・アーキテクチャの健全性の証明だ。

  • メインスレッドを常に軽量に保ち、長大なタスクは細かくチャンクする。
  • GCの発生を抑えるために、インタラクション中の不要なオブジェクト生成を排除する。
  • レイアウトスラッシングを回避し、ブラウザのレンダリングパイプラインに逆らわないコードを書く。

これらを愚直に実践できるエンジニアこそが、現代のWebブラウザという巨大な仮想マシンを完全に手なずけることができる。さあ、今すぐ自身のアプリケーションのプロファイラを開き、メインスレッドのタイムラインを美しく整えに行こう。

コメント

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