理論は忘れて――実際のNext.js本番サイトでLCPを2.5秒以下、CLSをゼロ、INPを200ms以下にするために私が実際にやったこと。曖昧なアドバイスではなく、具体的なテクニック。
このサイトを速くするのに丸2週間を費やしました。「M3 MacBookでのLighthouse監査で速く見える」という意味の速さではありません。本当に速い。150ドルのAndroidスマートフォンで、地下鉄のトンネル内の不安定な4G接続で速い。重要なところで速い、ということです。
結果:LCPは1.8秒以下、CLSは0.00、INPは120ms以下。3つすべてがラボスコアではなくCrUXデータでグリーン。そしてその過程で気づいたことがあります――インターネット上のパフォーマンスアドバイスのほとんどは、古いか、曖昧か、その両方です。
「画像を最適化しましょう」はアドバイスではありません。文脈なしの「遅延読み込みを使いましょう」は危険です。「JavaScriptを最小化しましょう」は当たり前ですが、何を削るべきかについては何も教えてくれません。
以下が、重要な順に、私が実際にやったことです。
率直に言います:GoogleはCore Web Vitalsをランキングシグナルとして使用しています。唯一のシグナルではなく、最も重要なシグナルですらありません。コンテンツの関連性、バックリンク、ドメインオーソリティは依然として支配的です。しかし、2つのページが同等のコンテンツと権威性を持つ境界線上では、パフォーマンスがタイブレーカーになります。そしてインターネット上では、数百万のページがその境界線上にあります。
でも一旦SEOは忘れてください。パフォーマンスを気にすべき本当の理由はユーザーです。データはここ5年でほとんど変わっていません:
2026年のCore Web Vitalsは3つのメトリクスで構成されています:
| メトリクス | 測定対象 | 良好 | 改善が必要 | 不良 |
|---|---|---|---|---|
| LCP | 読み込みパフォーマンス | ≤ 2.5秒 | 2.5秒 – 4.0秒 | > 4.0秒 |
| CLS | 視覚的安定性 | ≤ 0.1 | 0.1 – 0.25 | > 0.25 |
| INP | 応答性 | ≤ 200ms | 200ms – 500ms | > 500ms |
これらのしきい値は、2024年3月にINPがFIDに置き換わって以来変わっていません。しかし、それらを達成するためのテクニックは進化しており、特にReact/Next.jsエコシステムにおいて顕著です。
Largest Contentful Paintは、ビューポート内の最大の可視要素がレンダリングを完了するタイミングを測定します。ほとんどのページでは、ヒーロー画像、見出し、または大きなテキストブロックがこれに該当します。
何かを最適化する前に、LCP要素が何かを知る必要があります。皆ヒーロー画像だと思い込みます。しかし、<h1>をレンダリングするWebフォントだったりします。CSSで適用された背景画像だったりします。<video>のポスターフレームだったりします。
Chrome DevToolsを開き、Performanceパネルに移動してページロードを記録し、「LCP」マーカーを探してください。どの要素がLCPをトリガーしたかを正確に教えてくれます。
web-vitalsライブラリを使ってプログラム的にログを取ることもできます:
import { onLCP } from "web-vitals";
onLCP((metric) => {
console.log("LCP element:", metric.entries[0]?.element);
console.log("LCP value:", metric.value, "ms");
});このサイトでは、LCP要素はホームページのヒーロー画像と、ブログ記事の最初の段落テキストでした。2つの異なる要素、2つの異なる最適化戦略。
LCP要素が画像の場合、最もインパクトのある改善はプリロードです。デフォルトでは、ブラウザはHTMLをパースするときに画像を発見します。つまり、画像リクエストはHTMLがダウンロードされ、パースされ、<img>タグに到達するまで開始されません。プリロードはその発見を最初に移動させます。
Next.jsでは、レイアウトやページにプリロードリンクを追加できます:
import Head from "next/head";
export default function HeroSection() {
return (
<>
<Head>
<link
rel="preload"
as="image"
href="/images/hero-optimized.webp"
type="image/webp"
fetchPriority="high"
/>
</Head>
<section className="relative h-[600px]">
<img
src="/images/hero-optimized.webp"
alt="Hero banner"
width={1200}
height={600}
fetchPriority="high"
decoding="sync"
/>
</section>
</>
);
}fetchPriority="high"に注目してください。これは新しいFetch Priority APIで、ゲームチェンジャーです。これがないと、ブラウザは独自のヒューリスティクスを使ってリソースの優先順位を決定しますが、特にファーストビューに複数の画像がある場合、そのヒューリスティクスはしばしば間違います。
このサイトでは、LCP画像にfetchPriority="high"を追加したことでLCPが約400ms短縮されました。1行の変更で得られた史上最大の改善です。
CSSはレンダリングをブロックします。すべてです。<link rel="stylesheet">で読み込まれる200KBのスタイルシートがある場合、ブラウザは完全にダウンロードしてパースするまで何もペイントしません。
修正は3つのアプローチです:
クリティカルCSSのインライン化 ―― ファーストビューのコンテンツに必要なCSSを抽出し、<head>内の<style>タグにインライン化します。Next.jsはCSS ModulesやTailwindを適切なパージと共に使用すると、これを自動的に行います。
非クリティカルCSSの遅延読み込み ―― ファーストビュー以下のコンテンツ用のスタイルシート(フッターのアニメーションライブラリ、チャートコンポーネント)がある場合、非同期で読み込みます:
<link
rel="preload"
href="/styles/charts.css"
as="style"
onload="this.onload=null;this.rel='stylesheet'"
/>
<noscript>
<link rel="stylesheet" href="/styles/charts.css" />
</noscript>TTFBが遅ければLCPは速くなりません。サーバーのレスポンスに800msかかるなら、LCPは少なくとも800ms + それ以外のすべてになります。
このサイト(VPS上のNode.js + PM2 + Nginx)では、コールドヒット時のTTFBは約180msでした。それを維持するためにやったことはこうです:
Cache-Control: public, s-maxage=3600, stale-while-revalidate=86400。# nginx.conf snippet
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml;
gzip_min_length 1000;
gzip_comp_level 6;
# Brotli (if ngx_brotli module is installed)
brotli on;
brotli_types text/plain text/css application/json application/javascript text/xml;
brotli_comp_level 6;LCPのbefore/after:
Cumulative Layout Shiftは、ページ読み込み中に可視コンテンツがどれだけ動き回るかを測定します。CLSが0なら何もシフトしなかったことを意味します。CLSが0.1を超えると、何かがユーザーを視覚的にイライラさせていることを意味します。
CLSは最も過小評価されるメトリクスです。すべてがキャッシュされた高速な開発マシンでは気づきません。しかし、ユーザーは遅い接続のスマートフォンで、フォントが遅れて読み込まれ、画像が一つずつポップインする状況で気づきます。
1. 明示的なサイズが指定されていない画像
これが最も一般的なCLSの原因です。画像が読み込まれると、その下のコンテンツが押し下げられます。修正は驚くほどシンプルです:<img>タグには常にwidthとheightを指定しましょう。
// BAD — causes layout shift
<img src="/photo.jpg" alt="Team photo" />
// GOOD — browser reserves space before image loads
<img src="/photo.jpg" alt="Team photo" width={800} height={450} />Next.jsの<Image>を使用している場合、dimensionsを指定するか、サイズ指定された親コンテナでfillを使用すれば、自動的に処理されます。
しかし注意点があります:fillモードを使用する場合、親コンテナには明示的なサイズが必要です。そうしないと画像がCLSを引き起こします:
// BAD — parent has no dimensions
<div className="relative">
<Image src="/photo.jpg" alt="Team" fill />
</div>
// GOOD — parent has explicit aspect ratio
<div className="relative aspect-video w-full">
<Image src="/photo.jpg" alt="Team" fill sizes="100vw" />
</div>2. FOUT/FOITを引き起こすWebフォント
カスタムフォントが読み込まれると、フォールバックフォントでレンダリングされたテキストがカスタムフォントで再レンダリングされます。2つのフォントのメトリクスが異なる場合(ほぼ常に異なります)、すべてがシフトします。
現代的な修正はfont-display: swapとサイズ調整されたフォールバックフォントの組み合わせです:
// Using next/font — the best approach for Next.js
import { Inter } from "next/font/google";
const inter = Inter({
subsets: ["latin"],
display: "swap",
// next/font automatically generates size-adjusted fallback fonts
// This eliminates CLS from font swapping
});
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en" className={inter.className}>
<body>{children}</body>
</html>
);
}next/fontはNext.jsの中でも本当に優れた機能の一つです。ビルド時にフォントをダウンロードし、セルフホスティングし(ランタイムでのGoogle Fontsリクエストなし)、サイズ調整されたフォールバックフォントを生成するので、フォールバックからカスタムフォントへの切り替え時にレイアウトシフトがゼロになります。next/fontに切り替えた後、フォントによるCLSを0.00と測定しました。切り替え前は、標準のGoogle Fonts <link>で0.04-0.08でした。
3. 動的コンテンツの挿入
広告、Cookieバナー、通知バー ―― 初期レンダリング後にDOMに挿入されるものは、コンテンツを押し下げるとCLSを引き起こします。
修正:動的コンテンツが読み込まれる前にスペースを確保しましょう。
// Cookie banner — reserve space at the bottom
function CookieBanner() {
const [accepted, setAccepted] = useState(false);
if (accepted) return null;
return (
// Fixed positioning doesn't cause CLS because it
// doesn't affect document flow
<div className="fixed bottom-0 left-0 right-0 z-50 bg-gray-900 p-4">
<p>We use cookies. You know the drill.</p>
<button onClick={() => setAccepted(true)}>Accept</button>
</div>
);
}動的要素にposition: fixedやposition: absoluteを使用することは、CLSフリーなアプローチです。これらの要素は通常のドキュメントフローに影響しないからです。
4. aspect-ratio CSSのテクニック
アスペクト比はわかっているが正確なサイズがわからないレスポンシブコンテナには、CSSのaspect-ratioプロパティを使用します:
// Video embed without CLS
function VideoEmbed({ src }: { src: string }) {
return (
<div className="w-full aspect-video bg-gray-900 rounded-lg overflow-hidden">
<iframe
src={src}
className="w-full h-full"
title="Embedded video"
allow="accelerometer; autoplay; clipboard-write; encrypted-media"
allowFullScreen
/>
</div>
);
}aspect-videoユーティリティ(aspect-ratio: 16/9)は正確な量のスペースを確保します。iframeが読み込まれてもシフトなし。
5. スケルトンスクリーン
非同期で読み込まれるコンテンツ(APIデータ、動的コンポーネント)には、期待されるサイズに合致するスケルトンを表示します:
function PostCardSkeleton() {
return (
<div className="animate-pulse rounded-lg border p-4">
<div className="h-48 w-full rounded bg-gray-200" />
<div className="mt-4 space-y-2">
<div className="h-6 w-3/4 rounded bg-gray-200" />
<div className="h-4 w-full rounded bg-gray-200" />
<div className="h-4 w-5/6 rounded bg-gray-200" />
</div>
</div>
);
}
function PostList() {
const { data: posts, isLoading } = usePosts();
if (isLoading) {
return (
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 6 }).map((_, i) => (
<PostCardSkeleton key={i} />
))}
</div>
);
}
return (
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
{posts?.map((post) => (
<PostCard key={post.id} post={post} />
))}
</div>
);
}重要なのは、PostCardSkeletonとPostCardが同じサイズであることです。スケルトンが200pxの高さで実際のカードが280pxの高さだと、やはりシフトが発生します。
CLSの結果:
Interaction to Next Paintは2024年3月にFirst Input Delayに置き換わりましたが、最適化が根本的に難しいメトリクスです。FIDは最初のインタラクションが処理されるまでの遅延のみを測定していました。INPはページのライフサイクル全体を通じてすべてのインタラクションを測定し、最悪のもの(75パーセンタイル)を報告します。
つまり、ページのFIDは優秀でも、例えばロードから30秒後にドロップダウンメニューをクリックすると500msのリフローが発生するような場合、INPはひどい結果になり得ます。
offsetHeightなど)を読み取ってから書き込む(style.heightの変更など)と、ブラウザに同期的なレイアウト再計算を強制します。INPに対する最もインパクトのあるテクニックは、ブラウザがチャンク間でユーザーインタラクションを処理できるように長いタスクを分割することです:
async function processLargeDataset(items: DataItem[]) {
const results: ProcessedItem[] = [];
for (let i = 0; i < items.length; i++) {
results.push(expensiveTransform(items[i]));
// Every 10 items, yield to the browser
// This lets pending user interactions get processed
if (i % 10 === 0 && "scheduler" in globalThis) {
await scheduler.yield();
}
}
return results;
}scheduler.yield()はChrome 129以降(2024年9月)で利用可能で、メインスレッドに譲るための推奨方法です。サポートしていないブラウザには、setTimeout(0)ラッパーにフォールバックできます:
function yieldToMain(): Promise<void> {
if ("scheduler" in globalThis && "yield" in scheduler) {
return scheduler.yield();
}
return new Promise((resolve) => setTimeout(resolve, 0));
}React 18以降ではuseTransitionが提供され、特定の状態更新が緊急ではなく、より重要な作業(ユーザー入力への応答など)で中断できることをReactに伝えます:
import { useState, useTransition } from "react";
function SearchableList({ items }: { items: Item[] }) {
const [query, setQuery] = useState("");
const [filteredItems, setFilteredItems] = useState(items);
const [isPending, startTransition] = useTransition();
function handleSearch(e: React.ChangeEvent<HTMLInputElement>) {
const value = e.target.value;
// This update is urgent — the input must reflect the keystroke immediately
setQuery(value);
// This update is NOT urgent — filtering 10,000 items can wait
startTransition(() => {
const filtered = items.filter((item) =>
item.name.toLowerCase().includes(value.toLowerCase())
);
setFilteredItems(filtered);
});
}
return (
<div>
<input
type="text"
value={query}
onChange={handleSearch}
placeholder="Search..."
className="w-full rounded border px-4 py-2"
/>
{isPending && (
<p className="mt-2 text-sm text-gray-500">Filtering...</p>
)}
<ul className="mt-4 space-y-2">
{filteredItems.map((item) => (
<li key={item.id}>{item.name}</li>
))}
</ul>
</div>
);
}startTransitionがなければ、検索入力へのタイプ入力はもたつきます。Reactが10,000アイテムを同期的にフィルタリングしてからDOMを更新しようとするからです。startTransitionがあれば、入力は即座に更新され、フィルタリングはバックグラウンドで行われます。
複雑な入力ハンドラを持つツールページでINPを測定しました。useTransition導入前:INP 380ms。導入後:INP 90ms。React APIの変更だけで76%の改善です。
高コストな操作(API呼び出し、重い計算)をトリガーするハンドラには、デバウンスを適用します:
import { useCallback, useRef } from "react";
function useDebounce<T extends (...args: unknown[]) => void>(
fn: T,
delay: number
): T {
const timeoutRef = useRef<ReturnType<typeof setTimeout>>();
return useCallback(
((...args: unknown[]) => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
timeoutRef.current = setTimeout(() => fn(...args), delay);
}) as T,
[fn, delay]
);
}
// Usage
function LiveSearch() {
const [results, setResults] = useState<SearchResult[]>([]);
const search = useDebounce(async (query: string) => {
const response = await fetch(`/api/search?q=${encodeURIComponent(query)}`);
const data = await response.json();
setResults(data.results);
}, 300);
return (
<input
type="text"
onChange={(e) => search(e.target.value)}
placeholder="Search..."
/>
);
}300msが私の定番のデバウンス値です。ユーザーが遅延を感じないほど短く、キーストロークごとの発火を防ぐのに十分な長さです。
本当に重い計算(巨大なJSONのパース、画像操作、複雑な計算)がある場合は、メインスレッドから完全に切り離しましょう:
// worker.ts
self.addEventListener("message", (event) => {
const { data, operation } = event.data;
switch (operation) {
case "sort": {
// This could take 500ms for large datasets
const sorted = data.sort((a: number, b: number) => a - b);
self.postMessage({ result: sorted });
break;
}
case "filter": {
const filtered = data.filter((item: DataItem) =>
complexFilterLogic(item)
);
self.postMessage({ result: filtered });
break;
}
}
});// useWorker.ts
import { useEffect, useRef, useCallback } from "react";
function useWorker() {
const workerRef = useRef<Worker>();
useEffect(() => {
workerRef.current = new Worker(
new URL("../workers/worker.ts", import.meta.url)
);
return () => workerRef.current?.terminate();
}, []);
const process = useCallback(
(operation: string, data: unknown): Promise<unknown> => {
return new Promise((resolve) => {
if (!workerRef.current) return;
workerRef.current.onmessage = (event) => {
resolve(event.data.result);
};
workerRef.current.postMessage({ operation, data });
});
},
[]
);
return { process };
}Web Workerは別スレッドで動作するため、2秒の計算でもINPには全く影響しません。メインスレッドはユーザーインタラクションの処理のために自由なままです。
INPの結果:
Next.js(App Router付きの13以降)を使用している場合、ほとんどの開発者が十分に活用していない強力なパフォーマンスプリミティブにアクセスできます。
next/imageは優秀ですが、デフォルト設定ではパフォーマンスを取りこぼしています:
// next.config.ts
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
images: {
formats: ["image/avif", "image/webp"],
deviceSizes: [640, 750, 828, 1080, 1200],
imageSizes: [16, 32, 48, 64, 96, 128, 256],
minimumCacheTTL: 60 * 60 * 24 * 365, // 1 year
},
};
export default nextConfig;主要な設定:
formats: ["image/avif", "image/webp"] ―― AVIFはWebPより20-50%小さくなります。順序が重要です:Next.jsはまずAVIFを試み、WebPにフォールバックし、元のフォーマットにフォールバックします。minimumCacheTTL ―― デフォルトは60秒です。ブログでは画像は変わりません。1年間キャッシュしましょう。deviceSizesとimageSizes ―― デフォルトには3840pxが含まれています。4K画像を配信しない限り、このリストを削りましょう。各サイズは個別のキャッシュ画像を生成し、未使用のサイズはディスクスペースとビルド時間を無駄にします。そして常にsizesプロパティを使って、画像がどのサイズでレンダリングされるかをブラウザに伝えましょう:
// Full-width hero image
<Image
src="/hero.jpg"
alt="Hero"
width={1200}
height={600}
sizes="100vw"
priority // LCP image — don't lazy load it!
/>
// Card image in a responsive grid
<Image
src="/card.jpg"
alt="Card"
width={400}
height={300}
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
/>sizesがないと、ブラウザは300pxのスロットに1200pxの画像をダウンロードするかもしれません。バイトと時間の無駄です。
LCP画像のpriorityプロパティは重要です。遅延読み込みを無効にし、自動的にfetchPriority="high"を追加します。LCP要素がnext/imageなら、priorityを追加するだけでほぼ目標達成です。
CLSセクションで触れましたが、強調する価値があります。next/fontは、一貫してCLSゼロを達成する唯一のフォント読み込みソリューションです:
import { Inter, JetBrains_Mono } from "next/font/google";
const inter = Inter({
subsets: ["latin"],
display: "swap",
variable: "--font-inter",
});
const jetbrainsMono = JetBrains_Mono({
subsets: ["latin"],
display: "swap",
variable: "--font-mono",
});
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html
lang="en"
className={`${inter.variable} ${jetbrainsMono.variable}`}
>
<body className="font-sans">{children}</body>
</html>
);
}2つのフォント、CLSゼロ、ランタイムでの外部リクエストゼロ。フォントはビルド時にダウンロードされ、自ドメインから配信されます。
ここがNext.jsのパフォーマンスにおいて本当に面白くなるところです。App Routerを使えば、ページの一部を準備ができた順にブラウザへストリーミングできます:
import { Suspense } from "react";
import { PostList } from "@/components/blog/PostList";
import { Sidebar } from "@/components/blog/Sidebar";
import { PostListSkeleton } from "@/components/blog/PostListSkeleton";
import { SidebarSkeleton } from "@/components/blog/SidebarSkeleton";
export default function BlogPage() {
return (
<div className="grid grid-cols-1 gap-8 lg:grid-cols-3">
<div className="lg:col-span-2">
{/* This loads fast — stream it immediately */}
<h1 className="text-4xl font-bold">Blog</h1>
{/* This requires a database query — stream it when ready */}
<Suspense fallback={<PostListSkeleton />}>
<PostList />
</Suspense>
</div>
<aside>
{/* Sidebar can load independently */}
<Suspense fallback={<SidebarSkeleton />}>
<Sidebar />
</Suspense>
</aside>
</div>
);
}ブラウザはシェル(見出し、ナビゲーション、レイアウト)を即座に受け取ります。記事リストとサイドバーはデータが利用可能になった順にストリーミングされます。ユーザーは高速な初期表示を見て、コンテンツが段階的に埋まっていきます。
これはLCPにおいて特に強力です。LCP要素が見出し(記事リストではない)であれば、データベースクエリがどれだけ時間がかかっても即座にレンダリングされます。
Next.jsでは、ルートセグメントレベルでキャッシュと再検証を設定できます:
// app/blog/page.tsx
// Revalidate this page every hour
export const revalidate = 3600;
// app/tools/[slug]/page.tsx
// These tool pages are fully static — generate at build time
export const dynamic = "force-static";
// app/api/search/route.ts
// API route — never cache
export const dynamic = "force-dynamic";このサイトでは、ブログ記事はrevalidate = 3600(1時間)を使用しています。ツールページはデプロイ間でコンテンツが変わらないためforce-staticを使用。検索APIはリクエストが毎回一意なのでforce-dynamicを使用。
結果:ほとんどのページが静的キャッシュから配信され、キャッシュされたページのTTFBは50ms未満、サーバーはほとんど負荷がかかりません。
パフォーマンスに対するあなたの感覚は信頼できません。あなたの開発マシンには32GBのRAM、NVMe SSD、ギガビット接続があります。ユーザーにはありません。
1. Chrome DevTools Performanceパネル
利用可能な最も詳細なツールです。ページロードを記録し、フレームチャートを見て、長いタスクを特定し、レンダリングブロッキングリソースを見つけます。デバッグ時間の大半をここで過ごします。
注目すべきポイント:
2. Lighthouse
素早いチェックには良いですが、Lighthouseのスコアに最適化しないでください。Lighthouseはシミュレートされたスロットリング環境で実行され、実際の条件と完全には一致しません。Lighthouseで98点を取りながら、実際のLCPが4秒のページを見たことがあります。
Lighthouseは方向性のガイダンスとして使い、スコアボードとしては使わないでください。
3. PageSpeed Insights
本番サイトにとって最も重要なツールです。過去28日間の実際のChromeユーザーからの測定である実際のCrUXデータを表示するからです。ラボデータは起こりうることを教えてくれます。CrUXデータは実際に起こっていることを教えてくれます。
4. web-vitalsライブラリ
本番サイトに追加して、実際のユーザーメトリクスを収集しましょう:
// components/analytics/WebVitals.tsx
"use client";
import { useEffect } from "react";
import { onCLS, onINP, onLCP, onFCP, onTTFB } from "web-vitals";
import type { Metric } from "web-vitals";
function sendToAnalytics(metric: Metric) {
// Send to your analytics endpoint
const body = JSON.stringify({
name: metric.name,
value: metric.value,
rating: metric.rating, // "good" | "needs-improvement" | "poor"
delta: metric.delta,
id: metric.id,
navigationType: metric.navigationType,
});
// Use sendBeacon so it doesn't block page unload
if (navigator.sendBeacon) {
navigator.sendBeacon("/api/vitals", body);
} else {
fetch("/api/vitals", {
body,
method: "POST",
keepalive: true,
});
}
}
export function WebVitals() {
useEffect(() => {
onCLS(sendToAnalytics);
onINP(sendToAnalytics);
onLCP(sendToAnalytics);
onFCP(sendToAnalytics);
onTTFB(sendToAnalytics);
}, []);
return null;
}これにより、CrUXに似た独自のデータが得られますが、より詳細です。ページ、デバイスタイプ、接続速度、地理的地域など、必要に応じてセグメント化できます。
5. Chrome User Experience Report (CrUX)
CrUX BigQueryデータセットは無料で、数百万のオリジンの28日間のローリングデータを含んでいます。サイトに十分なトラフィックがあれば、自分のデータをクエリできます:
SELECT
origin,
p75_lcp,
p75_cls,
p75_inp,
form_factor
FROM
`chrome-ux-report.materialized.metrics_summary`
WHERE
origin = 'https://yoursite.com'
AND yyyymm = 202603サードパーティスクリプトは、ほとんどのWebサイトにおいてパフォーマンスキラーの筆頭です。私が発見したものと、それに対してやったことを紹介します。
GTM自体は約80KBです。しかしGTMは他のスクリプトを読み込みます ―― アナリティクス、マーケティングピクセル、A/Bテストツール。15個の追加スクリプトを合計2MB読み込むGTM設定を見たことがあります。
私のアプローチ:本番環境ではGTMを使わない。 アナリティクススクリプトを直接読み込み、すべてをdeferし、待てるスクリプトにはloading="lazy"を使用します:
// Instead of GTM loading everything
// Load only what you need, when you need it
export function AnalyticsScript() {
return (
<script
defer
src="https://analytics.example.com/script.js"
data-website-id="your-id"
/>
);
}どうしてもGTMを使う必要がある場合は、ページがインタラクティブになった後に読み込みましょう:
"use client";
import { useEffect } from "react";
export function DeferredGTM({ containerId }: { containerId: string }) {
useEffect(() => {
// Wait until after page load to inject GTM
const timer = setTimeout(() => {
const script = document.createElement("script");
script.src = `https://www.googletagmanager.com/gtm.js?id=${containerId}`;
script.async = true;
document.head.appendChild(script);
}, 3000); // 3 second delay
return () => clearTimeout(timer);
}, [containerId]);
return null;
}はい、最初の3秒で離脱するユーザーのデータは失われます。私の経験では、それは許容できるトレードオフです。そういうユーザーはどのみちコンバージョンしていませんでした。
ライブチャットウィジェット(Intercom、Drift、Crisp)は最悪の犯人の一つです。Intercomだけで400KB以上のJavaScriptを読み込みます。ユーザーの2%しかチャットボタンをクリックしないページで、98%のユーザーに400KBのJavaScriptを配信していることになります。
私の解決策:インタラクション時にウィジェットを読み込む。
"use client";
import { useState } from "react";
export function ChatButton() {
const [loaded, setLoaded] = useState(false);
function loadChat() {
if (loaded) return;
// Load the chat widget script only when the user clicks
const script = document.createElement("script");
script.src = "https://chat-widget.example.com/widget.js";
script.onload = () => {
// Initialize the widget after script loads
window.ChatWidget?.open();
};
document.head.appendChild(script);
setLoaded(true);
}
return (
<button
onClick={loadChat}
className="fixed bottom-4 right-4 rounded-full bg-blue-600 p-4 text-white shadow-lg"
aria-label="Open chat"
>
{loaded ? "Loading..." : "Chat with us"}
</button>
);
}Chrome DevToolsでCoverageを実行してください(Ctrl+Shift+P > "Show Coverage")。現在のページで各スクリプトがどれだけ実際に使用されているかを正確に示してくれます。
典型的なNext.jsサイトでは、通常以下のものが見つかります:
Buttonをインポートすると、ライブラリ全体がバンドルされます。解決策:ツリーシェイキング可能なライブラリを使うか、サブパスからインポートする(import { Button } from "lib"の代わりにimport Button from "lib/Button")。Promise、fetch、Array.prototype.includesのポリフィルを配信していないか確認してください。2026年には不要です。Next.jsバンドルアナライザーを使ってオーバーサイズのチャンクを見つけます:
// next.config.ts
import withBundleAnalyzer from "@next/bundle-analyzer";
const nextConfig = {
// your config
};
export default process.env.ANALYZE === "true"
? withBundleAnalyzer({ enabled: true })(nextConfig)
: nextConfig;ANALYZE=true npm run buildこれでバンドルのビジュアルツリーマップが開きます。120KBの日付フォーマットライブラリを発見し、ネイティブのIntl.DateTimeFormatに置き換えました。マークダウンを使わないページで90KBのマークダウンパーサーがインポートされているのを発見しました。積み重なる小さな改善です。
LCPセクションで触れましたが、非常に一般的なので繰り返す価値があります。<head>内のすべての<link rel="stylesheet">はレンダリングをブロックします。5つのスタイルシートがあれば、ブラウザは5つすべてを待ってから何かをペイントします。
TailwindとNext.jsはこれをうまく処理します ―― CSSはインライン化され最小限です。しかし、サードパーティCSSをインポートしている場合は監査してください:
// BAD — loads entire library CSS on every page
import "some-library/dist/styles.css";
// BETTER — dynamic import so it only loads on pages that need it
const SomeComponent = dynamic(
() => import("some-library").then((mod) => {
// CSS is imported inside the dynamic component
import("some-library/dist/styles.css");
return mod.SomeComponent;
}),
{ ssr: false }
);このサイトのツールページの実際の最適化を紹介しましょう。15以上のインタラクティブツールを持つページで、それぞれが独自のコンポーネントを持ち、正規表現テスターやJSONフォーマッターのようなものはJavaScriptが重い構成です。
初期測定値(CrUXデータ、モバイル、75パーセンタイル):
Lighthouseスコア:62。
LCP分析: LCP要素はページの見出し(<h1>)で、本来は即座にレンダリングされるべきでした。しかし、以下によって遅延していました:
CLS分析: 3つの原因:
INP分析: 正規表現テスターツールが最悪の犯人でした。正規表現入力のキーストロークごとに:
キーストロークあたりの合計時間:280-400ms。
第1週:LCPとCLS
Google Fonts CDNをnext/fontに置き換え。フォントはセルフホスティングされ、ビルド時に読み込まれ、サイズ調整されたフォールバック付き。フォントによるCLS:0.06 → 0.00
コンポーネントライブラリのCSSを削除。使用していた3つのコンポーネントをTailwindで書き直し。削除した合計CSS:180KB。レンダリングブロッキングCSS:排除
ツールページとツール詳細ページにrevalidate = 3600を追加。最初のアクセスはサーバーレンダリング、以降のアクセスはキャッシュから配信。TTFB:420ms → 45ms(キャッシュ時)
すべてのツールカードコンポーネントに明示的なサイズを追加し、レスポンシブレイアウトにaspect-ratioを使用。カードによるCLS:0.04 → 0.00
Cookieバナーを画面下部のposition: fixedに移動。バナーによるCLS:0.02 → 0.00
第2週:INP
startTransitionでラップ:function RegexTester() {
const [pattern, setPattern] = useState("");
const [testString, setTestString] = useState("");
const [results, setResults] = useState<RegexResult[]>([]);
const [isPending, startTransition] = useTransition();
function handlePatternChange(value: string) {
setPattern(value); // Urgent: update the input
startTransition(() => {
// Non-urgent: compute matches
try {
const regex = new RegExp(value, "g");
const matches = [...testString.matchAll(regex)];
setResults(
matches.map((m) => ({
match: m[0],
index: m.index ?? 0,
groups: m.groups,
}))
);
} catch {
setResults([]);
}
});
}
return (
<div>
<input
value={pattern}
onChange={(e) => handlePatternChange(e.target.value)}
className={isPending ? "opacity-70" : ""}
/>
{/* results rendering */}
</div>
);
}正規表現テスターのINP:380ms → 85ms
JSONフォーマッターの入力ハンドラにデバウンスを追加(300msの遅延)。JSONフォーマッターのINP:260ms → 60ms
ハッシュジェネレーターの計算をWeb Workerに移動。大きな入力のSHA-256ハッシュ化が完全にメインスレッド外で行われるようになりました。ハッシュジェネレーターのINP:200ms → 40ms
2週間の最適化後(CrUXデータ、モバイル、75パーセンタイル):
Lighthouseスコア:62 → 97。
3つのメトリクスすべてが確実に「良好」の範囲内。モバイルでページは瞬時に感じられます。そして改善後の1ヶ月間でオーガニック検索トラフィックが12%増加しました(因果関係は証明できませんが ―― 他の要因も働いていました)。
この記事から何も持ち帰らないとしても、すべてのプロジェクトで確認するチェックリストがこちらです:
priority(またはfetchPriority="high")を追加<head>でLCPリソースをプリロードnext/fontでフォントをセルフホスティングwidthとheightnext/fontを使用position: fixed/absoluteまたは事前確保されたスペースを使用startTransitionでラップパフォーマンス最適化は一回限りの作業ではありません。それは規律です。新しい機能、新しい依存関係、新しいサードパーティスクリプトのすべてがリグレッションの可能性を持っています。速さを維持するサイトは、誰かがメトリクスを継続的に監視しているサイトであり、一回限りの最適化スプリントをやったサイトではありません。
リアルユーザーモニタリングを設定しましょう。メトリクスがリグレッションしたときのアラートを設定しましょう。パフォーマンスをコードレビュープロセスの一部にしましょう。誰かが200KBのライブラリを追加したら、5KBの代替案がないか聞いてください。誰かがイベントハンドラに同期的な計算を追加したら、遅延できないかワーカーに移せないか聞いてください。
この記事のテクニックは理論的なものではありません。このサイトで私が実際にやったことであり、裏付けとなる実数があります。結果は異なるでしょう ―― すべてのサイトは違い、すべてのオーディエンスは違い、すべてのインフラは違います。しかし原則は普遍的です:読み込みを減らし、賢く読み込み、メインスレッドをブロックしないこと。
ユーザーは速いサイトに対してお礼のメッセージは送ってくれません。でも滞在してくれます。また来てくれます。そしてGoogleも気づきます。