Web Vitals INP改善ガイド|scheduler.yield・Long Task・Input Delay対策(2026)

(更新: 2026年6月22日 ) パフォーマンス INP Web Vitals JavaScript Core Web Vitals scheduler.yield
結論
  • INP改善の核心は、ユーザー操作の直前・直後にメインスレッドを50ms以上占有しないことだ。
  • Input DelayはLong Taskが原因であることがほとんどで、scheduler.yield()による処理分割、Web Workerへのオフロード、React startTransitionによる非緊急更新の延期が三種の神器になる。
  • 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で内訳を確認できる。

  1. Input Delay(入力遅延) — ユーザー操作〜イベントハンドラ実行開始まで
  2. Processing Time(処理時間) — イベントハンドラ本体の実行
  3. Presentation Delay(表示遅延) — ハンドラ完了〜次のペイントまで
180
Input Delay: 180
Input Delay
95
Processing Time: 95
Processing Time
40
Presentation Delay: 40
Presentation Delay
315
合計 INP: 315
合計 INP

上記は典型的な「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セレクタ、大規模リストの一括再描画が疑われる。

LCPとINPの関係

LCP改善は初期描画の話だが、hydration用JSが巨大だとLCPは良好でも初回クリックのInput Delayが悪化する。CRP最適化でJSをdeferし、必要な部分だけ先にhydrationする(Islands Architecture、Partial Hydration)のもINP対策の一環だ。

Long Task — INP悪化の最大要因

Long Taskとは、メインスレッド上で50ms以上連続実行されるタスクのことだ。ブラウザは入力イベントや描画(rAF)をLong Taskの完了まで待たせるため、Long Taskの真っ最中にユーザーがクリックするとInput Delayが発生する。

50msという閾値は、人間が操作と画面変化の因果を感じる約100msの半分——「操作に対するフィードバックが100ms以内なら即応している」と感じる、というUX研究に基づく。

Long Taskの発生源

発生源具体例対策の方向性
初期JSwebpackバンドル、polyfill、analyticscode 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 計測手順

1

Chrome DevTools → Performance insights → Measure page load で計測を開始する

2

計測中にフィルタ・ソート・モーダル開閉など、実ユーザーが行う操作を再現する

3

レポートの INP セクションで合計値と Input Delay / Processing / Presentation の内訳を確認する

4

Main スレッドのフレームビューで、操作直前の Long Task(赤三角)を特定する

5

Bottom-Up タブで Long Task の関数名・スクリプトURLを特定し、scheduler.yield または Worker 化を適用する

6

改善後、同じ 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が崩れるケースだ。対策は次のとおり。

  1. Critical JSだけ先に実行 — ルーティング・イベント委譲のみ。残りはimport()で遅延
  2. Islands / Partial Hydration — 静的コンテンツはHTMLのまま、インタラクティブ部分だけhydration
  3. 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改善チェックリスト

1

Search Console / PageSpeed Insights で Field INP(p75)の現状値を記録する

2

Performance Insights で Input Delay / Processing / Presentation の支配要因を特定する

3

Long Task Observer を staging に導入し、50ms 超タスクの発生箇所をログ収集する

4

同期ループ・フィルタ・CSV パースを scheduler.yield() または Web Worker に移行する

5

React/Vue では startTransition / defer 相当で非緊急更新を分離する

6

scroll / touch リスナーを passive: true にし、レイアウトスラッシングを解消する

7

サードパーティタグを Facade または遅延読み込みに切り替える

8

改善後 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とアクセシビリティ

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 SearchColor Name Search|色名から HEX を逆引き

まとめ

2026年のCore Web Vitalsにおいて、INPはページ全体の応答性を測る実用的な指標になった。FID時代の「最初の1クリックさえ速ければよい」設計は通用しない。

実務で効果が高い順に整理すると、次の5点になる。

  1. Long Taskの可視化 — PerformanceObserverとDevToolsでInput Delayの犯人を特定
  2. scheduler.yield() — 50ms超の同期処理をチャンク分割し、メインスレッドを譲渡
  3. Web Worker — 検索・集計・パースをメインスレッドから完全排除
  4. React startTransition — 非緊急の状態更新を入力処理より低優先度に
  5. LCPとの両立CRP最適化fetchpriorityで初期表示を速くしつつ、hydrationとサードパーティを制御してINPも200ms以内を維持

まずDevToolsで最も遅い操作を1つ再現し、その直前のLong Taskを1つ潰す——このサイクルを回せば、INPは着実に改善する。Field Dataの更新には最大28日かかるため、ラボ計測(TBT・Performance Insights)で短期フィードバックし、CrUXで最終確認するのが2026年の定石だ。