INP中心の画像配信最適化 2025 — decode/priority/スクリプト協調で体感を守る

公開: 2025年9月21日 · 読了目安: 4 · 著者: Unified Image Tools 編集部

はじめに

LCP 改善は画像最適化の中心課題ですが、2024–2025 の評価では INP(Interaction to Next Paint)悪化が見落とされがちです。重い画像のデコードやメインスレッドを占有する初期化処理は、初回操作後の応答を鈍らせます。特に、ユーザーが最初にタップ/スクロールした直後に「遅延読み込みの噴出」「画像デコード」「スクリプト初期化」が重なると、フレーム欠落や操作遅延が増幅されます。

本稿では「レイアウトからの過配信排除」を土台に、デコード/優先度制御/遅延とスクリプト協調の観点で、INP を悪化させない画像配信の実務を整理します。Next.js(App Router)/ブラウザ API の使い分け、ヒーローと下位画像の役割分担、そして実地計測(RUM)までをひとつのフレームワークにまとめます。

TL;DR

  • まず過配信を断つ(レイアウト由来の sizes/srcset 設計)
  • LCP 候補のみ priority/fetchPriority="high"、非LCPは decoding="async" と遅延
  • 初回操作の前後で「画像読み込み/デコード/スクリプト」を競合させない(協調)
  • プレースホルダーは CLS を出さない方式で(LQIP/BlurHash 等)

詳しいリサイズと sizes/srcset2025年のレスポンシブ画像設計 — srcset/sizes 実践ガイド2025年のリサイズ設計 — レイアウトから逆算して 30–70% の無駄を削る を参照。

なぜ画像が INP を悪化させるのか(簡易メンタルモデル)

ブラウザは「ネットワーク取得 → デコード → レイアウト/描画」という段階を踏みます。INP は「入力→次のペイント」までの遅延ですから、入力処理の前後に重い画像デコードや再レイアウトが走ると悪化します。

  • 入力直後に LazyLoad が大量発火すると、メインスレッドが decode/layout で埋まりイベント処理が遅延
  • サイズ過大な画像はデコード時間が長い(過配信は LCP/INP の両方を悪化)
  • スクリプトの初期化(パララックス、フィルタ、Canvas 処理)とデコードが衝突

回避策は「適正サイズ」「優先度の選別」「タイミング分離(協調)」の三位一体です。

INP を悪化させる典型パターン

  • 過大サイズ画像のデコードが入力処理と同時に発生
  • 初回操作に反応して、画像の LazyLoad が一斉に起動(スクロール/タップ直後のフレーム欠落)
  • 画像初期化(Canvas/フィルタ/アニメーション)がメインスレッドを塞ぐ

これらは「重さ」そのものよりも、タイミング競合が根本原因です。

アンチパターンと対策早見表

  • スクロール開始と同時に offscreen 画像を eager 化 → rootMargin を活用し、200–400px 手前で段階ロード
  • 低速端末でもヒーロー以外に priority 乱用 → LCP 候補のみ strict 運用(後述チェックリスト)
  • CSS アニメーション/Canvas と同時に大量 decode → 画像は idle/scheduler で時間をずらす

Next.js(App Router)での実装の型

// Hero: LCP 候補(初期表示)
<Image
  src="/hero-1536.avif"
  alt="製品ヒーロー"
  fill
  sizes="(max-width: 768px) 100vw, 768px"
  priority
  fetchPriority="high"
  decoding="sync"
/>

// 下位折り返し: 非LCP(視界に入る直前に)
<Image
  src="/gallery-640.webp"
  alt="ギャラリー"
  width={640}
  height={360}
  sizes="(max-width: 768px) 100vw, 768px"
  loading="lazy"
  decoding="async"
/>

ポイント:

  • LCP 候補のみ sync/priority/high。その他は lazy/async でメインスレッドを空ける
  • sizes が正しければ、ブラウザの選択は最適化される(過配信が INP/LCP を同時に悪化)

追加のヒント(リソースヒント/接続最適化)

// <head> でのヒント例(App Router: layout.tsx など)
export const metadata = {
  other: {
    link: [
      { rel: 'preconnect', href: 'https://cdn.example.com', crossOrigin: 'anonymous' },
    ],
  },
};
  • 画像 CDN へ preconnect すると往復遅延を短縮(ただし乱用は逆効果)
  • priority と組み合わせるのは LCP 候補のみ(ヒーロー/ファーストビューの代表画像)

遅延の粒度とスクリプト協調

  • ユーザー入力前後 300–500ms は重い decode/初期化を避ける
  • IntersectionObserver で「視界の手前」で事前ロードし、操作直後の競合を回避
  • 長い初期化(Canvas/フィルタ)は requestIdleCallback とスケジューリング
const io = new IntersectionObserver((entries) => {
  for (const e of entries) {
    if (e.isIntersecting) {
      // すぐ描画が必要なものだけを優先
      e.target.setAttribute('loading', 'eager');
      io.unobserve(e.target);
    }
  }
}, { rootMargin: '200px 0px' });

さらに、入力後すぐの 300ms で重い処理を避ける簡易スケジューラを併用します。

let lastInput = 0;
['pointerdown','keydown','wheel','touchstart'].forEach((t) => {
  window.addEventListener(t, () => (lastInput = performance.now()), { passive: true });
});

export function scheduleAfterInput<T>(task: () => T) {
  const dt = performance.now() - lastInput;
  if (dt < 300) {
    // 入力直後は少し遅らせる
    setTimeout(task, 300 - dt);
  } else {
    requestIdleCallback(() => task());
  }
}

プレースホルダーと CLS

CLS を出さない寸法指定(width/height or aspect-ratio)と、軽量な LQIP/SQIP/BlurHash を併用します。実装は プレースホルダー設計 LQIP/SQIP/BlurHash の実践 2025 を参照。

なお、ヒーローに過度なブラーを乗せると INP の改善に寄与しないことが多いです。プレースホルダーは「シンプルで軽い」が原則です。

計測とガードレール

  • field INP を RUM で収集(web-vitals)
  • 操作直後の Long Task を監視し、画像 decode/初期化の発火タイミングを見直す
  • Lighthouse/Timespan で操作シナリオを採点

RUM(実ユーザー計測)の例(簡略)

import { onINP, onLCP } from 'web-vitals/attribution';

onINP((metric) => {
  // 入力直後の競合を疑うとき、メタ情報を一緒に送る
  fetch('/rum', {
    method: 'POST',
    keepalive: true,
    body: JSON.stringify({
      name: metric.name,
      value: metric.value,
      entries: metric.entries?.length,
    }),
    headers: { 'content-type': 'application/json' },
  });
});

onLCP((metric) => {
  // LCP と画像優先度の相関も記録
});

手動検証(Timespan)

  • シナリオ: ページ表示 → 0.5s 後にスクロール → 画像が視界に入る直前

  • DevTools の Performance/Long Task を確認、デコードが操作と衝突していないかをチェック

  • DevTools の Performance/Long Task を確認、デコードが操作と衝突していないかをチェック

ケーススタディ(現場シナリオ)

事例1: LP のヒーロー + 折り返し直下ギャラリー

  • 症状: 初回スクロール直後に 20 枚のサムネイルが一斉に読み込み/デコード、INP p75 が 280→360ms に悪化
  • 原因: loading="lazy" の root 視界判定が厳密で、スクロール開始直後に大量ヒット。sizes が過小で過配信も発生
  • 対策:
    • sizes をレイアウト実寸に合わせて修正(過配信 35% 削減)
    • rootMargin: '300px 0px' で先読みを段階的に分散
    • requestIdleCallback でサムネイル初期化(ぼかし解除/アニメーション)を入力直後 300ms 以降に移動
  • 結果: INP p75 を 360→250ms に改善、LCP も 3% 向上

事例2: EC 商品リスト(フィルタ UI + 画像切り替え)

  • 症状: フィルタ押下後、画像差し替えと Canvas 加工が衝突して応答鈍化
  • 原因: 同期デコード + 即時 Canvas 描画でメインスレッド占有
  • 対策:
    • 画像は decoding="async" に統一、差し替えは fade-in で視覚許容
    • Canvas 加工は web worker へ逃がす or OffscreenCanvas
    • 重要でない加工はユーザー入力 300ms 以降に遅延
  • 結果: 操作直後のフレーム欠落が解消、INP p75 が 280→210ms

実装パターン(競合を避ける)

1) decode タイミングの制御

// 画像を生成しておき、idle で decode を前倒し
export function predecodeOnIdle(src: string) {
  const img = new Image();
  img.src = src;
  requestIdleCallback(async () => {
    try { await img.decode(); } catch {}
  });
}

注意: 大量の事前 decode は逆効果。可視直前の 1–3 枚に限定。

2) scheduler.postTask で優先度分離(対応ブラウザのみ)

// @ts-ignore: 型ガードはプロダクションで厳密に
const schedulerAny: any = (globalThis as any).scheduler;

export async function afterInputLow(task: () => void) {
  // 入力直後は低優先度へ
  const dt = performance.now() - (window as any).__lastInputTs ?? 0;
  if (dt < 300 && schedulerAny?.postTask) {
    await schedulerAny.postTask(task, { priority: 'background', delay: 300 - dt });
  } else {
    requestIdleCallback(task);
  }
}

イベントフック:

['pointerdown','keydown','wheel','touchstart'].forEach((t) => {
  addEventListener(t, () => ((window as any).__lastInputTs = performance.now()), { passive: true });
});

3) IntersectionObserver の段階ロード

const io = new IntersectionObserver((entries) => {
  for (const e of entries) {
    if (e.isIntersecting) {
      const el = e.target as HTMLImageElement;
      // 見えた直前は eager、遠いものは lazy のまま
      el.loading = 'eager';
      io.unobserve(el);
    }
  }
}, { rootMargin: '300px 0px' });

export function watch(img: HTMLImageElement) { io.observe(img); }

4) ネットワーク条件で rootMargin を切替

function chooseRootMargin() {
  const n = (navigator as any).connection;
  if (!n) return '200px 0px';
  if (n.saveData) return '150px 0px';
  if (n.effectiveType?.includes('2g')) return '400px 0px';
  return '300px 0px';
}

Next.js での落とし込み(App Router)

サーバ/クライアントの役割分担

  • サーバ: 正しい sizes をレイアウトから算出して埋め込む
  • クライアント: 入力イベント観測と初期化タスクの抑制(300ms 窓)
  • 画像 CDN の最適化はプリセット化し、src は指紋付き URL に固定

ヒーローとギャラリーのテンプレート化

// components/HeroImage.tsx
export function HeroImage(props: Omit<React.ComponentProps<'img'>, 'decoding'|'loading'>) {
  return (
    <img {...props} decoding="sync" fetchPriority="high" />
  );
}

// components/GalleryImage.tsx
export function GalleryImage(props: Omit<React.ComponentProps<'img'>, 'decoding'>) {
  return (
    <img {...props} loading="lazy" decoding="async" />
  );
}

プロジェクト全体で HeroImage/GalleryImage を利用し、lint で直書きの priority を禁止します。

監視(Long Task / Event Timing)

// Long Task を観測
new PerformanceObserver((list) => {
  for (const e of list.getEntries()) {
    const lt = e as PerformanceEntry & { duration: number };
    if (lt.duration > 50) {
      // 入力直後の 300ms 窓に重なっていないかを確認
    }
  }
}).observe({ type: 'longtask', buffered: true });

// Event Timing (INP の素地)
new PerformanceObserver((list) => {
  for (const e of list.getEntries()) {
    // e.name (click, keydown など) / processingStart / duration
  }
}).observe({ type: 'event', buffered: true });

RUM では p50/p75/p95 を分けて保存し、デプロイごとのリグレッションを検出します。ユーザーエージェント/ネットワーク種別別に切ると再現が取りやすくなります。

FAQ(よくある質問)

Q. priority を複数の画像に付けてもいい?

A. 原則 NG。ヒーロー 1 枚(またはカルーセル 1 枚目)に限定。複数付与はネットワーク飽和と decode 競合の温床。

Q. decoding="sync" はいつ使う?

A. LCP 候補で、視覚的に初期表示の一部である画像のみ。その他は async

Q. 画像 CDN への preconnect は効果ある?

A. ファーストビューで CDN を叩くなら有効。ただしリンク数が多いと相殺されるため、1–2 ドメインに限定。

Q. Blur プレースホルダーは INP に効く?

A. 直接はほぼ効かない。CLS を避ける/主観的な読み込み感を出すための補助と捉える。

ガイドライン(チェックリスト拡張)

  • [ ] レイアウトに一致する sizes を全画像に付与(過配信ゼロを目標)
  • [ ] LCP 候補のみ priority/fetchPriority="high"/decoding="sync"
  • [ ] 非 LCP は loading="lazy" + decoding="async"
  • [ ] 入力直後 300–500ms 窓に重い decode/初期化を置かない
  • [ ] rootMargin で段階ロード(200–400px 目安、接続速度で調整)
  • [ ] RUM で INP/LCP 相関を記録、p75 を主指標に
  • [ ] Lint/CI で priority の乱用・sizes 欠落を検出

まとめ

INP を守る鍵は「過配信を断つ」「優先度を正しく」「操作直前後に重い処理を置かない」の3点です。LCP 施策に加えて、タイミング協調の設計を加えるだけで、体感は大きく改善します。仕上げに RUM で回帰を監視し、ヒーロー以外の priority を禁止するルール(lint/CI)を運用へ組み込みましょう。

チェックリスト(配信用):

  • [ ] LCP 候補のみ priority/fetchPriority="high"
  • [ ] 非 LCP は loading="lazy" + decoding="async"
  • [ ] sizes はレイアウトと一致(過配信なし)
  • [ ] rootMargin で段階ロード、入力直後 300ms は重処理なし
  • [ ] width/height or aspect-ratio 指定で CLS=0 を目指す

関連記事

基礎

画像最適化の基本 2025 — 勘に頼らない土台づくり

どのサイトにも効く、速くて美しい配信のための最新ベーシック。リサイズ→圧縮→レスポンシブ→キャッシュの順で安定運用に。

Web

画像SEO 2025 — alt・構造化データ・サイトマップの実務

検索流入を逃さない画像SEOの最新実装。altテキスト/ファイル名/構造化データ/画像サイトマップ/LCP最適化を一つの方針で整えます。

圧縮

画像圧縮 完全戦略 2025 ─ 画質を守りつつ体感速度を最適化する実践ガイド

Core Web Vitals と実運用に効く最新の画像圧縮戦略を、用途別の具体的プリセット・コード・ワークフローで徹底解説。JPEG/PNG/WebP/AVIF の使い分け、ビルド/配信最適化、トラブル診断まで網羅。

Web

アクセシビリティ実務の画像 — alt/装飾/図解の線引き 2025

スクリーンリーダーで破綻しない画像の実装。装飾は空alt、意味画像は簡潔に、図解は本文を要約。リンク画像とOGPの注意点も。

Web

Favicon & PWA アセット チェックリスト 2025 — マニフェスト/アイコン/SEO シグナル

見落としがちなファビコン/PWA アセットの要点。マニフェストのローカライズや配線、必要サイズの網羅をチェックリスト化。

変換

フォーマット変換の戦略 2025 — WebP/AVIF/JPEG/PNG を使い分ける指針

コンテンツ種別ごとの意思決定と運用フロー。互換性・容量・画質のバランスを取り、最小の労力で安定化。