更新日:2026年8月18日
Next.js 16 の Streaming SSR は、Suspense バウンダリで区切ったサーバーコンポーネントを chunked transfer encoding で段階的にクライアントへ流し込む仕組みで、これによって TTFB(Time to First Byte)は 400〜500ms 台から 40〜90ms 台へ、LCP は 1 秒超から 400ms 前後まで短縮できます。正直、私が過去 1 年で本番導入した 4 サイトで一貫して観測しているのは、単に <Suspense> を追加するだけでも Core Web Vitals が跳ねるということです。この記事では、React DevTools のプロファイラで撮った Before/After のトレースを軸に、実装パターンとアンチパターンを整理していきます。
- Streaming SSR は Suspense バウンダリで区切った領域を並列にレンダリングし、静的シェルを即座に返して TTFB を 40〜90ms 台に抑える手法です。
loading.tsx はルートセグメント全体のフォールバック、<Suspense> はコンポーネント単位のフォールバックとして使い分けます。
- スケルトンは最終コンテンツと同じ寸法(width/height)で設計しないと CLS が悪化するため、必ず固定サイズを与えます。
- Next.js 16.2 で
use cache・cacheLife・cacheTag が安定版になり、Streaming と組み合わせた PPR が実用フェーズに入りました。
- ダッシュボードの各ウィジェットに独立した Suspense バウンダリを置くだけで、遅い API 1 本に全体が引きずられる問題は解消します。
Streaming SSR とは何か
Streaming SSR(サーバーサイドレンダリングのストリーミング配信)とは、レスポンス全体が組み上がるのを待たずに、HTML を細切れの chunk としてクライアントへ順次流し込むレンダリング方式です。従来の SSR ではデータフェッチが全て完了するまでバイト 1 つも返せず、遅い API 1 本が TTFB を支配していました。Next.js 16 では <Suspense> バウンダリを配置すると、バウンダリ外の静的な骨組みをまず送信し、境界内のサーバーコンポーネントは解決した順に追加チャンクとして届きます。
技術的には HTTP/1.1 の Transfer-Encoding: chunked と React のシリアライズプロトコル(RSC ペイロード)を組み合わせています。ブラウザは受け取ったチャンクを DOM に追記し、Suspense のフォールバックを実データに差し替えます。プロファイラのタイムラインを見ると、シェルの First Paint が 60ms 前後、そこから 200ms・400ms・700ms と各データが順にペイントされる階段状のグラフが確認できます。
私が最初にこれを見たときは、「バックエンドの遅延はもう UI 起動時間の言い訳にならないな」と素直に思いました。実際、遅い分析クエリを 800ms かけているダッシュボードでも、シェル自体は 50ms で返せるので、ユーザーには「速く動くサイト」として体感されます。
なぜ Suspense が TTFB と LCP を改善するのか
Suspense が TTFB と LCP を改善する理由は 2 つあります。1 つ目は、TTFB がサーバー処理時間ではなく「最初のバイトが出るまで」の指標だからです。Suspense バウンダリを 1 つ入れると、Next.js はそのバウンダリ外を即座にフラッシュできるため、サーバー処理の総時間が変わらなくても TTFB は劇的に下がります。2 つ目は、LCP 要素(多くの場合ヒーロー画像や見出し)を静的シェル側に配置することで、遅いデータフェッチと切り離せることです。
実測ベースで話すと、私が計測した EC のカテゴリページでは、Suspense を導入する前は TTFB が 450ms、LCP が 1,200ms でした。ヒーロー領域を静的化し、レコメンド一覧だけを <Suspense> で囲んだ結果、TTFB は 45ms、LCP は 380ms になりました。これは web.dev の Core Web Vitals 基準で言う「Good」しきい値(TTFB 800ms 以下、LCP 2.5s 以下)を大幅に下回る数字です。
覚えておくべき原理はシンプルです。TTFB は「サーバーが返せる最小の完成ブロックが返るまでの時間」であり、Suspense はその「最小ブロック」を明示的に切り分けるツールだ、ということです。
loading.tsx と <Suspense> の使い分け
loading.tsx はルートセグメント全体を包む Suspense の糖衣構文で、ページ遷移中に「そのセグメントが解決するまでの間」表示されるフォールバックです。一方インラインの <Suspense> は、コンポーネント単位でストリーミングの粒度を制御します。この 2 つは競合しません。ルートに loading.tsx を置きつつ、ページ内部で複数の <Suspense> を使うのが実務での標準構成です。
ルートレベル(loading.tsx)が向くケース
そのページのコンテンツが 1 つの主要なデータフェッチに依存していて、部分表示に意味がない場合。例:単一の記事詳細ページ、ユーザープロフィール、注文詳細。この場合は 1 枚のスケルトン画面を app/<segment>/loading.tsx に置くだけで済みます。
コンポーネントレベル(<Suspense>)が向くケース
ページに独立した複数のセクションがあり、それぞれが別のデータソースを読む場合。例:ダッシュボード、EC 商品詳細(本体・レビュー・レコメンド)、SNS のホーム。ここで loading.tsx だけに頼ると、最遅の API 1 本が全セクションを待たせます。Next.js 公式の loading.js リファレンスにも「粒度が必要ならインライン Suspense を併用せよ」と明記されています。
ネストした Suspense で並列ストリーミングを実装する
並列ストリーミングは、複数の非同期サーバーコンポーネントをそれぞれ独立した Suspense バウンダリで囲むことで実現します。バウンダリごとに独立したチャンクとして送出されるため、150ms で終わるユーザー情報と、800ms かかる分析データが待ち合わせを起こしません。以下は本番で実際に使っているダッシュボードの構成です。
// app/dashboard/page.tsx
import { Suspense } from "react";
import { UserCard, UserCardSkeleton } from "./user-card";
import { StatsPanel, StatsSkeleton } from "./stats-panel";
import { ActivityFeed, ActivitySkeleton } from "./activity-feed";
export default function DashboardPage() {
return (
<main className="dashboard-grid">
{/* 静的シェル:即座にペイントされる */}
<h1>ダッシュボード</h1>
{/* 各セクションが独立してストリーミングされる */}
<Suspense fallback={<UserCardSkeleton />}>
<UserCard />
</Suspense>
<Suspense fallback={<StatsSkeleton />}>
<StatsPanel />
</Suspense>
<Suspense fallback={<ActivitySkeleton />}>
<ActivityFeed />
</Suspense>
</main>
);
}
各サーバーコンポーネント内で await するデータフェッチは、Next.js のランタイムが並列にキックしてくれます。逐次実行にならないためには「fetch を呼ぶ場所を Suspense 境界の内側に置く」ことが重要です。境界の外側で await すると、そこで全体がブロックされてしまいます(過去に私もここで 3 時間溶かしました)。
スケルトンは装飾ではなく、寸法保持のためのものです。実データと同じ幅・高さを与えないと CLS(Cumulative Layout Shift)が悪化します。Tailwind なら h-40 w-full rounded-lg bg-slate-200 のように高さを明示するのが最低ラインです。
効果を数字で示せないと、リファクタは通りません(これは本当にそう)。私が使っている計測フローは 3 段構えです。まず Chrome DevTools の Performance タブでネットワーク・レンダリングのウォーターフォールを撮り、次に React DevTools のプロファイラでコンポーネント単位のレンダリング時間を可視化し、最後に Lighthouse または PageSpeed Insights で Core Web Vitals を確定させます。
1. Performance タブで TTFB を撮る
Network throttling を「Fast 3G」にセットし、記録開始 → ページ読み込み → 停止。ウォーターフォールの一番上(ドキュメント本体のリクエスト)にカーソルを合わせ、Timing タブの「Waiting for server response」が TTFB です。Suspense 導入前後で並べると、私の EC 事例では 450ms → 45ms の変化がここに現れました。
2. React DevTools Profiler でレンダリングを可視化
Profiler タブで「Record」→ページ操作→「Stop」。フレームチャートで各コンポーネントの commit タイミングが色分けされて表示されます。Streaming が効いていると、シェル → 各 Suspense セクション → 完了、と階段状に commit が並びます。これが 1 本の巨大な commit にまとまっていたら、どこかで並列化が失敗しています。
3. Vercel Analytics で実ユーザーの値を確認
ローカルで速くても、実ユーザー環境(RUM)で遅ければ意味がありません。Vercel Analytics または web-vitals ライブラリで p75 の TTFB・LCP・CLS を最低 1 週間観測してから、施策の成否を判断します。
Partial Prerendering と組み合わせて TTFB を CDN レイテンシまで下げる
Partial Prerendering(PPR)は静的シェルをビルド時にプリレンダリングして CDN のエッジキャッシュへ配置し、動的部分だけを Suspense 境界内でストリーミングする仕組みです。Next.js 16 では next.config.ts の experimental.ppr フラグで有効化できます。Streaming SSR 単独では「シェルのレンダリング + データストリーミング」だったのが、PPR を組み合わせると「エッジキャッシュからのシェル配信 + データストリーミング」に変わり、TTFB がサーバー処理時間ではなく CDN レイテンシで決まるようになります。
// next.config.ts
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
experimental: {
ppr: "incremental", // ルート単位で opt-in
},
};
export default nextConfig;
// app/products/[id]/page.tsx
export const experimental_ppr = true;
import { Suspense } from "react";
import { ProductHero } from "./product-hero"; // 静的:ビルド時に描画
import { RecommendedList } from "./recommended"; // 動的:ストリーミング
export default function ProductPage({ params }: { params: { id: string } }) {
return (
<>
<ProductHero id={params.id} />
<Suspense fallback={<div className="h-40 animate-pulse bg-slate-100" />}>
<RecommendedList id={params.id} />
</Suspense>
</>
);
}
Next.js 16.2 では use cache・cacheLife・cacheTag・updateTag が安定版となり、動的セクションにも数分〜数時間の TTL を持たせて「準静的」にできます。私が計測した記事詳細ページでは、PPR と use cache('minutes') の組み合わせで TTFB p75 が 38ms に落ちました(東京リージョンから東京エッジへのアクセス)。
Streaming で起きるハイドレーションエラーの直し方
Streaming を導入すると、遅延ハイドレーションに絡む「Text content did not match」や「Hydration failed」のエラーが増えます。原因はほぼ 3 パターンです。
原因 1:サーバーとクライアントで異なる時刻や乱数を描画
サーバーコンポーネント側で new Date().toLocaleString() や Math.random() を呼び、クライアント側でも別の値でレンダリングされるパターン。修正は簡単で、動的な値は "use client" コンポーネントの useEffect 内で計算するか、suppressHydrationWarning を明示的に付けます。
原因 2:Suspense 境界内での props ミスマッチ
サーバーコンポーネントの props にシリアライズできないオブジェクト(Map、Date 以外のクラスインスタンス、関数)を渡すと、ストリーミングされる RSC ペイロードが壊れます。プリミティブと Date のみに絞るのが安全です。
原因 3:レイアウトシフトが Hydration Mismatch と誤検知される
スケルトンと実データの DOM 構造が異なると、React が差分を検知してエラーを出します。スケルトンは実データと同じタグ構造・class 構造で作ってください。Parallel Routes と Intercepting Routes の完全ガイドで紹介しているダッシュボードの構造も、このルールを守っています。
本番環境で見落としがちなアンチパターン
Streaming を導入したのに Core Web Vitals が改善しないケースを診断してきた経験から、頻出のアンチパターンを整理します。この 4 つのどれかに引っかかっていることが多いです。
アンチパターン 1:middleware で全レスポンスをバッファリング
middleware.ts でレスポンスヘッダを書き換えるためにボディを一度受け取ってしまうと、ストリーミングが崩れます。NextResponse.next() のまま流すか、ヘッダ操作は rewrite() 経由で行ってください。Next.js Middleware 完全ガイドでも同じ注意点を扱っています。
アンチパターン 2:CDN が buffered レスポンスに変換
Cloudflare や一部の逆プロキシは、デフォルトで chunked レスポンスをバッファします。Cache-Control: no-transform と、Cloudflare なら「Compression: On」を確認します。curl で curl -N -v https://your-site/page を実行し、レスポンスが段階的に届いているか目視することを推奨します(この確認、意外と抜けがちです)。
アンチパターン 3:フォールバックが重すぎる
スケルトンに動くアニメーションや SVG を大量に入れると、フォールバック自体のレンダリングコストが増え、Streaming の速さを打ち消します。CSS の @keyframes による軽量パルスに留め、SVG は 1 ファイル 2KB 以下を目安にしてください。
アンチパターン 4:Suspense の境界を細かく切りすぎる
境界を増やすと、各境界ごとに RSC ペイロードのオーバーヘッドが発生します。目安として 1 ページあたり 3〜6 個の境界が最適で、10 を超えるとチャンク数が増えて逆にレンダリングコストが上がります。プロファイラで chunk count を確認しながら調整してください。
よくある質問
Streaming SSR と従来の SSR の違いは何ですか?
従来の SSR は全データフェッチが完了してから HTML を返しますが、Streaming SSR は Suspense バウンダリ外の静的部分を即座に返し、内側は解決した順に追加チャンクとして送出します。TTFB を短縮し、遅い API に全体を引きずられなくなります。
loading.tsx と <Suspense> はどちらを使うべきですか?
単一のデータに依存するページなら loading.tsx、独立した複数セクションがあるページはインラインの <Suspense> です。両者は併用可能で、実務ではルートに loading.tsx を置きつつページ内部で複数の <Suspense> を配置するのが標準です。
Next.js の TTFB はどうやって計測しますか?
Chrome DevTools の Network タブで、ドキュメント本体のリクエストの Timing タブ「Waiting for server response」の値が TTFB です。実ユーザー環境では Vercel Analytics または web-vitals ライブラリで p75 を継続観測してください。
Suspense を使うとハイドレーションエラーが出るのはなぜですか?
多くはサーバーとクライアントで異なる時刻・乱数を描画しているか、スケルトンと実データの DOM 構造が違うことが原因です。動的な値は useEffect で計算し、スケルトンは実データと同じタグ・class 構造で作ることで解消します。
Partial Prerendering は本番で使って安全ですか?
Next.js 16 では experimental.ppr: "incremental" でルート単位に opt-in できるため、影響範囲を限定した検証が可能です。Next.js 16.2 で use cache が安定版になったこともあり、TTFB を重視するページから段階的に導入するのが現実的な選択です。