Web Vitals INP改善ガイド|scheduler.yield・Long Task・Input Delay対策(2026)
- INP改善の核心は、ユーザー操作の直前・直後にメインスレッドを50ms以上占有しないことだ。
- Input DelayはLong Taskが原因であることがほとんどで、
scheduler.yield()による処理分割、Web Workerへのオフロード、ReactstartTransitionによる非緊急更新の延期が三種の神器になる。 - LCPはクリティカルレンダリングパス最適化とfetchpriorityで、INPはインタラクション後のJS実行で——2026年のCore Web Vitalsはこの2軸を同時に満たす設計が求められる。
INP(Interaction to Next Paint)とは — 2026年Core Web Vitals
INP(Interaction to Next Paint)は、Googleが2024年3月にCore Web Vitalsへ正式採用したインタラクション応答性の指標だ。旧指標のFID(First Input Delay)が「最初の1回の入力」だけを見ていたのに対し、INPはページ滞在中のすべての操作のうち、最も遅かった(または75パーセンタイルの)応答時間を評価する。
| 評価 | INP(ミリ秒) | 意味 |
|---|---|---|
| 良好(Good) | 200ms以下 | ユーザーが「サクサク動く」と感じる |
| 改善が必要(Needs Improvement) | 200〜500ms | 操作にわずかな遅延を感じる |
| 不良(Poor) | 500ms超 | 明らかなもたつき、離脱要因になりうる |
INPが測定対象とするインタラクションは、クリック、タップ、キーボード操作(Enter・Space含む)だ。ホバーやスクロールそのものはINPに含まれないが、スクロール中に走る重いscroll/wheelハンドラはLong Taskを生み、次のクリックのInput Delayを悪化させる——この間接効果を見落としがちなので注意が必要だ。
FIDからINPへの移行が意味すること
FIDは「最初の入力までにJSがメインスレッドを塞いでいないか」という初期体験の1点だけを見ていた。SPAやダッシュボードのように、ユーザーがページを開いた後も長時間操作し続けるアプリでは、2回目以降のクリックが500ms以上遅れてもFIDは良好のまま——という盲点があった。
INPはこの盲点を解消する。フィルタ適用、ソート切替、モーダル開閉、無限スクロールの追加読み込みなど、滞在中の最悪操作が評価対象になる。2026年現在、Search ConsoleのCore Web Vitalsレポート、CrUX(Chrome User Experience Report)、PageSpeed InsightsはすべてINPを表示し、FIDは参考値扱いまたは非表示になっている。
| 指標 | 測定対象・特徴 |
|---|---|
| FID(旧) | 最初の1回の入力のみ。ロード直後のInput Delayに特化。2024年3月以降Core Web Vitalsから除外 |
| INP(現行) | 全インタラクションの遅延(最悪値またはp75)。SPA・ダッシュボードの実態に近い |
| TBT(Lighthouse) | Long Taskの合計ブロック時間。ラボ計測の代理指標。Field INPとの相関は高いが一致しない場合もある |
| CLS / LCP | 視覚的安定性・読み込み速度。INPと独立だが、重いhydrationはLCP後のINP悪化に波及 |
INPの3要素:Input Delay・Processing Time・Presentation Delay
INPは1回のインタラクションについて、次の3段階の合計時間として分解できる。DevToolsのPerformance Insights → INP breakdownで内訳を確認できる。
- Input Delay(入力遅延) — ユーザー操作〜イベントハンドラ実行開始まで
- Processing Time(処理時間) — イベントハンドラ本体の実行
- Presentation Delay(表示遅延) — ハンドラ完了〜次のペイントまで
上記は典型的な「INP 315ms・改善が必要」ケースの内訳イメージだ。Input Delay 180msが支配的——つまりハンドラ自体は95msで終わっているのに、操作から180ms間メインスレッドが他の処理で塞がれていた、という状態を示している。
Input Delayが支配的な典型パターン
Input Delayが大きいとき、原因はほぼ次のいずれかだ。
- Long Task(50ms超) — バンドル解析、大量DOM更新、サードパーティタグ
- React hydration — SSR直後のクライアント側再構築
- 同期的なlocalStorage読み書き — メインスレッドをブロック
- 重い
scroll/resizeハンドラ — 操作直前に走っていた
Processing Timeが支配的なら、ハンドラ内のアルゴリズム改善(インデックス構築、メモ化、Worker移譲)が先だ。Presentation Delayが大きい場合は、大量のレイアウトスラッシング(読み取り→書き込み→読み取りの連鎖)や、重いCSSセレクタ、大規模リストの一括再描画が疑われる。
Long Task — INP悪化の最大要因
Long Taskとは、メインスレッド上で50ms以上連続実行されるタスクのことだ。ブラウザは入力イベントや描画(rAF)をLong Taskの完了まで待たせるため、Long Taskの真っ最中にユーザーがクリックするとInput Delayが発生する。
50msという閾値は、人間が操作と画面変化の因果を感じる約100msの半分——「操作に対するフィードバックが100ms以内なら即応している」と感じる、というUX研究に基づく。
Long Taskの発生源
| 発生源 | 具体例 | 対策の方向性 |
|---|---|---|
| 初期JS | webpackバンドル、polyfill、analytics | code splitting、defer、遅延読み込み |
| フレームワーク | React hydration、Vue mount | 部分hydration、startTransition |
| アプリロジック | 1万件フィルタ、CSVパース | Worker、チャンク分割、scheduler.yield |
| サードパーティ | チャットウィジェット、A/Bテスト | 遅延注入、facade pattern |
| DevTools計測 | PerformanceObserver自身 | 本番計測はサンプリング |
Long Task検出 — PerformanceObserver(完全な実装例)
本番・ステージングでLong Taskを記録する最小構成のJavaScriptだ。
/**
* Long Task を PerformanceObserver で記録し、
* 分析基盤(例: Google Analytics 4)へ送信する。
*/
(function initLongTaskObserver() {
if (typeof PerformanceObserver === 'undefined') {
return;
}
const LONG_TASK_THRESHOLD_MS = 50;
const buffer = [];
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
const duration = entry.duration;
if (duration < LONG_TASK_THRESHOLD_MS) {
continue;
}
const attribution = entry.attribution?.[0];
const detail = {
duration: Math.round(duration),
startTime: Math.round(entry.startTime),
name: entry.name,
containerType: attribution?.containerType ?? 'unknown',
containerSrc: attribution?.containerSrc ?? '',
containerId: attribution?.containerId ?? '',
containerName: attribution?.containerName ?? '',
};
buffer.push(detail);
if (typeof window.gtag === 'function') {
window.gtag('event', 'long_task', {
event_category: 'Web Vitals',
value: detail.duration,
task_duration: detail.duration,
task_start: detail.startTime,
container_type: detail.containerType,
});
}
if (process.env.NODE_ENV === 'development') {
console.warn('[Long Task]', detail.duration + 'ms', detail);
}
}
});
try {
observer.observe({ type: 'longtask', buffered: true });
} catch (err) {
console.warn('longtask observer not supported', err);
}
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden' && buffer.length > 0) {
const payload = JSON.stringify({ longTasks: buffer, url: location.href });
navigator.sendBeacon('/api/vitals/long-tasks', payload);
buffer.length = 0;
}
});
})();
DevToolsでは、Performanceパネルのメインスレッド上に赤い三角付きのバーがLong Taskとして表示される。クリックイベントの直前にLong Taskがあれば、Input Delayの犯人としてほぼ確定できる。
scheduler.yield() — メインスレッドを意図的に譲渡する
scheduler.yield()はScheduler APIの一部で、長い同期処理の途中でを呼ぶと、メインスレッドをいったん解放し、待機中の入力イベント・描画・他のタスクを処理させてから、yield呼び出し元の処理を再開する。
従来のsetTimeout(fn, 0)やrequestAnimationFrameより、ブラウザのタスクキューと統合された形で「譲渡」できるのが利点だ。INP改善の文脈では、Long Taskを複数の50ms未満タスクに分割するための最も直接的なAPIと言える。
scheduler.yield()の基本パターン(完全な実装例)
1万件の配列をフィルタしながら、定期的にメインスレッドを譲渡する例だ。
/**
* 大量データをチャンク処理し、scheduler.yield() で
* メインスレッドを定期的に譲渡する。
*/
async function filterLargeDataset(items, predicate, options = {}) {
const chunkSize = options.chunkSize ?? 200;
const maxWorkPerChunkMs = options.maxWorkPerChunkMs ?? 16;
const results = [];
let index = 0;
while (index < items.length) {
const chunkStart = performance.now();
while (index < items.length) {
const item = items[index];
if (predicate(item)) {
results.push(item);
}
index += 1;
const elapsed = performance.now() - chunkStart;
if (index % chunkSize === 0 || elapsed >= maxWorkPerChunkMs) {
break;
}
}
if (index < items.length) {
if ('scheduler' in globalThis && 'yield' in globalThis.scheduler) {
await scheduler.yield();
} else {
await new Promise((resolve) => setTimeout(resolve, 0));
}
}
}
return results;
}
const allProducts = Array.from({ length: 50000 }, (_, i) => ({
id: i,
category: i % 7 === 0 ? 'sale' : 'regular',
price: 1000 + (i % 500),
}));
document.getElementById('filter-sale').addEventListener('click', async () => {
const button = document.getElementById('filter-sale');
button.disabled = true;
button.textContent = 'フィルタ中…';
const filtered = await filterLargeDataset(
allProducts,
(p) => p.category === 'sale',
{ chunkSize: 300, maxWorkPerChunkMs: 12 }
);
renderProductList(filtered);
button.disabled = false;
button.textContent = 'セール商品のみ';
});
function renderProductList(products) {
const list = document.getElementById('product-list');
list.replaceChildren();
const fragment = document.createDocumentFragment();
for (const p of products.slice(0, 100)) {
const li = document.createElement('li');
li.textContent = 'ID:' + p.id + ' ¥' + p.price;
fragment.appendChild(li);
}
list.appendChild(fragment);
}
maxWorkPerChunkMs: 12は、60fps(1フレーム16.6ms)の余裕を残すための目安だ。チャンクサイズ固定より経過時間ベースでyieldする方が、端末性能差に強い。
Polyfill — scheduler.yield() 非対応ブラウザ向け(完全な実装例)
Safari・Firefox向けに、同等の譲渡動作を提供するユーティリティだ。
/**
* scheduler.yield() の Polyfill。
* 非対応環境では setTimeout(0) でタスクキューを空にする。
*/
const taskYield = (() => {
if (
typeof globalThis.scheduler !== 'undefined' &&
typeof globalThis.scheduler.yield === 'function'
) {
return () => globalThis.scheduler.yield();
}
return () =>
new Promise((resolve) => {
setTimeout(resolve, 0);
});
})();
/**
* 同期処理を時間予算内で分割実行するヘルパー。
*/
async function runInChunks(items, processItem, options = {}) {
const budgetMs = options.budgetMs ?? 16;
let processed = 0;
for (let i = 0; i < items.length; i += 1) {
const sliceStart = performance.now();
while (i < items.length && performance.now() - sliceStart < budgetMs) {
processItem(items[i], i);
processed += 1;
i += 1;
}
i -= 1;
if (i < items.length - 1) {
await taskYield();
}
}
return processed;
}
async function updateDashboardMetrics(rows) {
const container = document.getElementById('metrics');
container.setAttribute('aria-busy', 'true');
await runInChunks(
rows,
(row) => {
const cell = document.querySelector('[data-row-id="' + row.id + '"]');
if (cell) {
cell.textContent = row.value.toLocaleString('ja-JP');
}
},
{ budgetMs: 10 }
);
container.removeAttribute('aria-busy');
}
Input Delayを直接削るイベントハンドラ設計
Input Delayは「ハンドラの中身」よりハンドラが始まるまでの待ちが問題になることが多い。以下の設計原則は、Processing Timeを増やさずInput Delayだけを削る。
1. パッシブリスナーでスクロールをブロックしない
/**
* タッチ・ホイールイベントをパッシブ登録し、
* スクロール中のメインスレッドブロックを防ぐ。
*/
function initScrollTracking() {
let scrollDepthMax = 0;
let ticking = false;
function onScroll() {
const depth = window.scrollY + window.innerHeight;
scrollDepthMax = Math.max(scrollDepthMax, depth);
if (!ticking) {
ticking = true;
requestAnimationFrame(() => {
document.documentElement.dataset.scrollDepth = String(scrollDepthMax);
ticking = false;
});
}
}
window.addEventListener('scroll', onScroll, { passive: true });
window.addEventListener('wheel', onScroll, { passive: true });
window.addEventListener('touchmove', onScroll, { passive: true });
}
2. 即時フィードバック + yield 後に重い処理
検索ボックスで、Input Delay中にユーザーが「反応がない」と感じないよう、視覚的フィードバックは即時、重い検索はscheduler.yield()後に実行する。
/**
* 検索 UI: 即時スピナー表示 + yield 後に重い検索実行
*/
function createSearchController(searchIndex) {
const input = document.getElementById('search-input');
const resultsEl = document.getElementById('search-results');
const spinner = document.getElementById('search-spinner');
let currentQuery = '';
let abortVersion = 0;
input.addEventListener('input', async (event) => {
const query = event.target.value.trim();
currentQuery = query;
const version = ++abortVersion;
spinner.hidden = false;
resultsEl.setAttribute('aria-busy', 'true');
if ('scheduler' in globalThis && 'yield' in globalThis.scheduler) {
await scheduler.yield();
} else {
await new Promise((r) => setTimeout(r, 0));
}
if (version !== abortVersion || query !== currentQuery) {
return;
}
if (query.length === 0) {
resultsEl.replaceChildren();
spinner.hidden = true;
resultsEl.removeAttribute('aria-busy');
return;
}
const matches = searchIndex.filter((item) =>
item.title.toLowerCase().includes(query.toLowerCase())
);
if (version !== abortVersion) {
return;
}
renderSearchResults(resultsEl, matches.slice(0, 20));
spinner.hidden = true;
resultsEl.removeAttribute('aria-busy');
});
}
function renderSearchResults(container, items) {
container.replaceChildren();
const frag = document.createDocumentFragment();
for (const item of items) {
const li = document.createElement('li');
const a = document.createElement('a');
a.href = item.url;
a.textContent = item.title;
li.appendChild(a);
frag.appendChild(li);
}
container.appendChild(frag);
}
3. React — startTransition による非緊急更新(完全な実装例)
Reactアプリでは、状態更新をstartTransitionで包むと、ブラウザが入力イベントを優先処理できる。
import { useState, useTransition, useMemo, useCallback } from 'react';
const PRODUCTS = Array.from({ length: 10000 }, (_, i) => ({
id: i,
name: '商品 ' + i,
tag: i % 5 === 0 ? 'new' : 'standard',
}));
export function ProductFilterApp() {
const [query, setQuery] = useState('');
const [tag, setTag] = useState('all');
const [isPending, startTransition] = useTransition();
const filtered = useMemo(() => {
const q = query.toLowerCase();
return PRODUCTS.filter((p) => {
const matchTag = tag === 'all' || p.tag === tag;
const matchQuery = q === '' || p.name.toLowerCase().includes(q);
return matchTag && matchQuery;
});
}, [query, tag]);
const handleQueryChange = useCallback((event) => {
const value = event.target.value;
startTransition(() => {
setQuery(value);
});
}, []);
const handleTagChange = useCallback((event) => {
const value = event.target.value;
startTransition(() => {
setTag(value);
});
}, []);
return (
<div className="product-filter" aria-busy={isPending}>
<input
type="search"
value={query}
onChange={handleQueryChange}
placeholder="商品名で検索"
aria-label="商品検索"
/>
<select value={tag} onChange={handleTagChange} aria-label="タグフィルタ">
<option value="all">すべて</option>
<option value="new">新着</option>
<option value="standard">通常</option>
</select>
{isPending && <p role="status">更新中…</p>}
<ul>
{filtered.slice(0, 50).map((p) => (
<li key={p.id}>{p.name}</li>
))}
</ul>
<p>{filtered.length} 件ヒット(最大50件表示)</p>
</div>
);
}
useTransitionはReact 18以降で利用可能。2026年の新規プロジェクトではConcurrent Featuresを前提に、クリック直後のUI(ボタンdisabled、スピナー)は通常のsetState、リスト再計算はstartTransitionと役割分担するのが定石だ。
Web Worker — 重い計算をメインスレッドから完全排除
scheduler.yield()はLong Taskを短くするが、そもそも重い計算をメインスレッドで走らせないのが最善だ。CSVパース、画像処理、全文検索インデックス構築はWeb Workerへ移す。
Worker側スクリプト(完全な実装例)
// search-worker.js — Dedicated Worker
const catalog = [];
self.onmessage = function onWorkerMessage(event) {
const { type, payload, requestId } = event.data;
if (type === 'BUILD_INDEX') {
const start = performance.now();
catalog.length = 0;
for (const item of payload.items) {
catalog.push({
id: item.id,
title: item.title,
titleLower: item.title.toLowerCase(),
url: item.url,
});
}
const duration = performance.now() - start;
self.postMessage({
type: 'INDEX_READY',
requestId,
count: catalog.length,
durationMs: Math.round(duration),
});
return;
}
if (type === 'SEARCH') {
const query = payload.query.toLowerCase();
const start = performance.now();
const results = [];
for (let i = 0; i < catalog.length; i += 1) {
const item = catalog[i];
if (item.titleLower.includes(query)) {
results.push({ id: item.id, title: item.title, url: item.url });
if (results.length >= payload.limit) {
break;
}
}
}
const duration = performance.now() - start;
self.postMessage({
type: 'SEARCH_RESULT',
requestId,
results,
durationMs: Math.round(duration),
});
}
};
メインスレッド側 — Workerラッパー(完全な実装例)
/**
* SearchWorkerClient — INP に影響しない検索処理
*/
class SearchWorkerClient {
constructor(workerUrl) {
this.worker = new Worker(workerUrl, { type: 'module' });
this.pending = new Map();
this.nextRequestId = 1;
this.worker.onmessage = (event) => {
const { requestId, type } = event.data;
const resolver = this.pending.get(requestId);
if (!resolver) {
return;
}
this.pending.delete(requestId);
if (type === 'SEARCH_RESULT') {
resolver.resolve(event.data);
} else if (type === 'INDEX_READY') {
resolver.resolve(event.data);
}
};
this.worker.onerror = (error) => {
for (const [, resolver] of this.pending) {
resolver.reject(error);
}
this.pending.clear();
};
}
_send(type, payload) {
const requestId = this.nextRequestId++;
return new Promise((resolve, reject) => {
this.pending.set(requestId, { resolve, reject });
this.worker.postMessage({ type, payload, requestId });
});
}
buildIndex(items) {
return this._send('BUILD_INDEX', { items });
}
search(query, limit = 20) {
return this._send('SEARCH', { query, limit });
}
terminate() {
this.worker.terminate();
this.pending.clear();
}
}
const searchClient = new SearchWorkerClient('/workers/search-worker.js');
document.getElementById('catalog-search').addEventListener('input', async (event) => {
const query = event.target.value.trim();
const resultsEl = document.getElementById('catalog-results');
if (query.length === 0) {
resultsEl.replaceChildren();
return;
}
resultsEl.setAttribute('aria-busy', 'true');
try {
const { results, durationMs } = await searchClient.search(query, 15);
resultsEl.dataset.searchDuration = String(durationMs);
resultsEl.replaceChildren();
const frag = document.createDocumentFragment();
for (const item of results) {
const li = document.createElement('li');
const a = document.createElement('a');
a.href = item.url;
a.textContent = item.title;
li.appendChild(a);
frag.appendChild(li);
}
resultsEl.appendChild(frag);
} finally {
resultsEl.removeAttribute('aria-busy');
}
});
Worker内の処理時間が200msを超えても、メインスレッドのInput Delayには影響しない。結果のDOM反映だけがINPにカウントされるため、DocumentFragmentによる一括挿入でPresentation Delayも最小化する。
web-vitals ライブラリで INP を本番計測する
Field Data(実ユーザーデータ)でINPを把握するには、web-vitalsライブラリが標準的だ。Lighthouseのラボ計測だけでは、端末差・ネットワーク差・操作パターン差を反映できない。
import { onINP, onLCP, onCLS } from 'web-vitals/attribution';
function sendToAnalytics(metric) {
const body = {
name: metric.name,
value: metric.value,
rating: metric.rating,
id: metric.id,
navigationType: metric.navigationType,
attribution: metric.attribution,
page: location.pathname,
};
if (navigator.sendBeacon) {
navigator.sendBeacon('/api/vitals', JSON.stringify(body));
} else {
fetch('/api/vitals', {
method: 'POST',
body: JSON.stringify(body),
keepalive: true,
headers: { 'Content-Type': 'application/json' },
});
}
}
onINP(sendToAnalytics);
onLCP(sendToAnalytics);
onCLS(sendToAnalytics);
onINP((metric) => {
const { interactionTarget, interactionType, inputDelay, processingDuration, presentationDelay } =
metric.attribution;
if (metric.rating === 'poor' || metric.rating === 'needs-improvement') {
console.info('[INP]', {
value: metric.value,
target: interactionTarget,
type: interactionType,
inputDelay,
processingDuration,
presentationDelay,
});
}
});
attribution付きのonINPを使うと、どの要素へのどの種類の操作で、Input Delay / Processing / Presentation のどれが支配的だったかが取れる。Long Task Observerと突合すれば、改善優先度の根拠がデータで説明できる。
Presentation Delayを削る — レイアウトスラッシング回避
Presentation Delayは、ハンドラ完了後のスタイル計算・レイアウト・ペイントに要する時間だ。次の「悪い例 → 良い例」は、同一機能でもPresentation Delayが数倍変わる典型パターンだ。
悪い例:読み取りと書き込みの交互実行
function updateCardsBad(cards) {
const container = document.getElementById('card-grid');
for (const card of cards) {
const el = container.querySelector('[data-id="' + card.id + '"]');
const height = el.offsetHeight;
el.style.minHeight = height + 'px';
el.classList.toggle('active', card.active);
el.querySelector('.label').textContent = card.label;
}
}
offsetHeight(読み取り)の直後にstyle.minHeight(書き込み)を繰り返すと、ブラウザは毎回レイアウトを再計算する——レイアウトスラッシングだ。
良い例:読み取りフェーズと書き込みフェーズを分離
function updateCardsGood(cards) {
const container = document.getElementById('card-grid');
const measurements = cards.map((card) => {
const el = container.querySelector('[data-id="' + card.id + '"]');
return { el, card, height: el.offsetHeight };
});
requestAnimationFrame(() => {
for (const { el, card, height } of measurements) {
el.style.minHeight = height + 'px';
el.classList.toggle('active', card.active);
el.querySelector('.label').textContent = card.label;
}
});
}
読み取りを一括で終えてから、次のフレームで書き込む。INPのPresentation Delayは、このパターンだけで数十ms改善することがある。
サードパーティスクリプトと INP
チャットボット、ヒートマップ、A/Bテスト、広告タグは、メインスレッド上でLong Taskを量産しやすい。LCP向けのCRP最適化と同様、読み込みタイミングの制御がINPにも効く。
| 手法 | 内容 | INPへの効果 |
|---|---|---|
| Facade Pattern | 静的プレースホルダーを表示し、ユーザー操作後に本物を読み込む | 初期Long Task排除 |
| Partytown | サードパーティJSをWeb Workerへオフロード | メインスレッド解放 |
| tag manager 遅延 | DOMContentLoaded後・初回スクロール後に発火 | 初回クリック前のInput Delay削減 |
| import-on-interaction | モーダル・チャット起動時だけSDK読み込み | 不要時のLong Taskゼロ |
/**
* Facade Pattern — チャットウィジェットを操作後に読み込む
*/
function initChatFacade() {
const launcher = document.getElementById('chat-launcher');
let loaded = false;
launcher.addEventListener('click', async () => {
if (loaded) {
window.ChatWidget.open();
return;
}
launcher.setAttribute('aria-busy', 'true');
launcher.textContent = '読み込み中…';
if ('scheduler' in globalThis && 'yield' in globalThis.scheduler) {
await scheduler.yield();
}
const script = document.createElement('script');
script.src = 'https://vendor.example/chat-widget.js';
script.async = true;
script.onload = () => {
loaded = true;
launcher.removeAttribute('aria-busy');
launcher.textContent = 'チャット';
window.ChatWidget.init({ locale: 'ja' });
window.ChatWidget.open();
};
document.head.appendChild(script);
});
}
DevTools による INP 計測手順
Chrome DevTools → Performance insights → Measure page load で計測を開始する
計測中にフィルタ・ソート・モーダル開閉など、実ユーザーが行う操作を再現する
レポートの INP セクションで合計値と Input Delay / Processing / Presentation の内訳を確認する
Main スレッドのフレームビューで、操作直前の Long Task(赤三角)を特定する
Bottom-Up タブで Long Task の関数名・スクリプトURLを特定し、scheduler.yield または Worker 化を適用する
改善後、同じ throttling(4G Slow + 4x CPU)で再計測し、INP 200ms 以内を確認する
Lighthouse と Field Data の使い分け
| 計測源 | 強み | 弱み |
|---|---|---|
| Lighthouse(ラボ) | 再現性が高い。TBTでLong Task傾向を掴める | 実際の操作パターンを反映しにくい |
| CrUX / Search Console(フィールド) | 実ユーザーのp75 INP。SEO評価に直結 | 原因特定にはDevToolsが別途必要 |
| web-vitals + attribution(自前) | 悪化操作・要素・内訳をカスタム送信 | 実装・集計基盤が必要 |
2026年の実務フローは、Search ConsoleでINP不良URLを特定 → DevToolsで操作再現 → 修正 → CrUX更新を28日後に確認が基本だ。Lighthouseだけで「INP良好」と出ても、Field Dataが不良のケースは珍しくない。
2026年 Core Web Vitals 全体像 — LCP・INP・CLS の設計バランス
Core Web Vitalsは3指標すべてを同時に満たす必要がある。LCPだけ速くても、クリックが500ms遅れればINP不良だ。
| 指標 | 2026年の目標値と主対策 |
|---|---|
| LCP | 2.5秒以内。[CRP最適化](/blog/critical-rendering-path-最適化/)、[fetchpriority](/blog/fetchpriority-lcp-画像-最適化-2026/)、画像フォーマット |
| INP | 200ms以内。scheduler.yield、Worker、startTransition、Long Task分割 |
| CLS | 0.1以内。width/height指定、font-display: swap、動的コンテンツのスペース確保 |
| TTFB | 800ms以内(参考)。CDN、キャッシュ、Edge SSR |
初期表示とインタラクションのトレードオフを避ける
よくある失敗パターンは、LCP改善のために巨大なJSバンドルを<head>でpreloadし、結果としてhydration Long TaskでINPが崩れるケースだ。対策は次のとおり。
- Critical JSだけ先に実行 — ルーティング・イベント委譲のみ。残りは
import()で遅延 - Islands / Partial Hydration — 静的コンテンツはHTMLのまま、インタラクティブ部分だけhydration
- Idle時プリフェッチ —
requestIdleCallbackで次画面のチャンクを先読み(操作直前のLong Task化に注意)
/**
* requestIdleCallback で非クリティカルモジュールを先読み。
* timeout を付け、idle が来ない場合も無限待ちしない。
*/
function prefetchWhenIdle(moduleUrl) {
const load = () => {
import(moduleUrl).catch((err) => {
console.warn('Prefetch failed:', moduleUrl, err);
});
};
if ('requestIdleCallback' in window) {
requestIdleCallback(load, { timeout: 3000 });
} else {
setTimeout(load, 2000);
}
}
document.addEventListener('DOMContentLoaded', () => {
prefetchWhenIdle('/chunks/admin-dashboard.js');
prefetchWhenIdle('/chunks/chart-renderer.js');
});
INP改善チェックリスト
Search Console / PageSpeed Insights で Field INP(p75)の現状値を記録する
Performance Insights で Input Delay / Processing / Presentation の支配要因を特定する
Long Task Observer を staging に導入し、50ms 超タスクの発生箇所をログ収集する
同期ループ・フィルタ・CSV パースを scheduler.yield() または Web Worker に移行する
React/Vue では startTransition / defer 相当で非緊急更新を分離する
scroll / touch リスナーを passive: true にし、レイアウトスラッシングを解消する
サードパーティタグを Facade または遅延読み込みに切り替える
改善後 28 日間の CrUX 推移と Lighthouse TBT を比較記録する
よくある落とし穴
- debounceだけ増やす — Input Delayは減らない。操作「開始」の遅延だけ増える
- yieldしすぎ — チャンクが小さすぎるとオーバーヘッドでProcessing Timeが増える。10〜16ms予算が目安
- Web WorkerにDOM操作 — Worker内でDOMは触れない。結果だけメインスレッドへ返す
- 計測なしの最適化 — attribution なし INP では「どのボタンが遅いか」がわからない
- LCPだけ改善して満足 — fetchpriorityでLCP 1.8秒でも、フィルタクリック INP 600ms なら Core Web Vitals 不合格
INP改善の過程で、スピナーやの付け忘れがあると、スクリーンリーダーユーザーは「操作が効いているかわからない」状態になる。即時フィードバック(disabled化・aria-busy・role=status)は、INP数値とUXの両方のために必須だ。
関連記事
この記事は Webパフォーマンス テーマの一環です。あわせて読むと理解が深まる関連記事をまとめました。
| トピック | 記事 |
|---|---|
| HTTP/2とHTTP/3の違い | HTTP/2とHTTP/3の違い|QUICがWeb表示速度を変える理由 |
| Speculation Rules API 完全ガイド | Speculation Rules API 完全ガイド|prefetch・prerender・Chrome 144 prerender_until_script |
| content-visibility: auto 完全ガイド | content-visibility: auto 完全ガイド|遅延レンダリングと長尺ページ最適化 2026 |
| Server-Timing API 完全ガイド | Server-Timing API 完全ガイド|Express ミドルウェアと Chrome DevTools 連携 |
| Brotli vs Gzip 圧縮比較 | Brotli vs Gzip 圧縮比較|nginx静的・動的設定とCDN実践ガイド |
| CSS/JSを圧縮(Minify)するメリット | CSS/JSを圧縮(Minify)するメリット|サイト高速化の基本 |
| Service Worker キャッシュ戦略ガイド | Service Worker キャッシュ戦略ガイド|PWA の cache-first・network-first・SWR・Workbox 設計 |
| View Transitions API 完全ガイド | View Transitions API 完全ガイド|SPA・MPA ページ遷移と Astro/React 統合 |
| WebP変換で画質が落ちる原因と最適な設定 | WebP変換で画質が落ちる原因と最適な設定 |
| 画像圧縮とWebP変換 | 画像圧縮とWebP変換|PageSpeed改善の基本 |
| React SSR Hydration Mismatch 原因と修正 | React SSR Hydration Mismatch 原因と修正|Next.js・Astro対応ガイド |
| Color Name Search | Color Name Search|色名から HEX を逆引き |
まとめ
2026年のCore Web Vitalsにおいて、INPはページ全体の応答性を測る実用的な指標になった。FID時代の「最初の1クリックさえ速ければよい」設計は通用しない。
実務で効果が高い順に整理すると、次の5点になる。
- Long Taskの可視化 — PerformanceObserverとDevToolsでInput Delayの犯人を特定
- scheduler.yield() — 50ms超の同期処理をチャンク分割し、メインスレッドを譲渡
- Web Worker — 検索・集計・パースをメインスレッドから完全排除
- React startTransition — 非緊急の状態更新を入力処理より低優先度に
- LCPとの両立 — CRP最適化とfetchpriorityで初期表示を速くしつつ、hydrationとサードパーティを制御してINPも200ms以内を維持
まずDevToolsで最も遅い操作を1つ再現し、その直前のLong Taskを1つ潰す——このサイクルを回せば、INPは着実に改善する。Field Dataの更新には最大28日かかるため、ラボ計測(TBT・Performance Insights)で短期フィードバックし、CrUXで最終確認するのが2026年の定石だ。