every Tech Blog

株式会社エブリーのTech Blogです。

Next.js 16.3のPartial Prefetchingをプロダクトで検証してみる

目次

はじめに

こんにちは、開発本部の黒髙です。最近はアメリカ向けレシピサービスであるOISHYの開発に関わっています。

Next.js 16.3では、ページ遷移の応答性を改善する仕組みとしてInstant Navigationsが追加されました。クリック前に再利用できるUIやデータを準備し、クリック後に必要な部分を取得することで、遷移直後から次のページを描画しやすくする仕組みです。

Instant Navigationsを構成する機能のうち、今回取り上げるのはPartial Prefetchingです。本記事では、Partial Prefetchingを紹介したうえで、OISHYの既存ページでは通信と画面遷移にどのように現れるのかを検証します。

Partial Prefetchingは何を解決するのか

すべてを先読みする方法と、何も先読みしない方法の中間を作る

Next.jsの<Link>によるprefetch(先読み)では、リンクが画面内に入ると、ユーザーがクリックする前に遷移先のデータを取得します。クリック時にデータが揃っていれば、遷移先をすぐに表示できます。

一方、リンクが多い画面では、実際にはクリックされないリンクのデータも取得します。先読みを無効にすれば事前通信はなくなりますが、その場合はクリックしてから遷移先のデータを取得することになります。

Next.js 16.3のPartial Prefetchingは、この二つの中間を作る機能です。Cache Componentsを使うページへの既定の<Link>では、URLごとの完成形をすべて先読みする代わりに、同じ種類のページで再利用できるApp Shellを先読みします。クリックされたURLに固有のデータは、必要になった時点で取得します。

App Shellとは、URL固有のデータを待たずに表示できるページの共通部分です。サイト共通のレイアウトや、データの読み込み中に表示するUIなどが含まれます。

レシピ詳細ページを例にすると、レシピ名や材料はURLごとに変わりますが、それらを待っている間の枠組みは複数のレシピで共有できます。同じルートを指す複数のリンクで一つのApp Shellを再利用できるため、リンクごとに同じ共通部分を取得し直す必要もありません。

この仕組みは、次のようなページで効果が現れやすいと考えられます。

  • 商品、記事、レシピなど、同じ種類の詳細ページへのリンクが多数並ぶ
  • 表示されたリンクのうち、実際にクリックされるのは一部だけである
  • 詳細ページに、共通レイアウトやデータ取得中の表示など、URLをまたいで再利用できる部分がある

OISHYのトップページにも、同じ/recipes/[id]へ遷移するレシピリンクが多数並びます。そこで今回は、レシピ詳細ページをPartial Prefetchingへ切り替えたとき、クリック前の通信とクリック後の表示がどう変わるかを確かめました。

OISHYでPartial Prefetchingを試す

OISHYでは、すでにCache Componentsを有効にしています。まず、レシピ詳細ページへの遷移にPartial Prefetchingを適用するため、次の設定を追加しました。

// app/recipes/[id]/page.tsx
export const prefetch = 'partial';

公式リファレンスでは、prefetch = 'partial'はリンクではなく遷移先に設定するものと説明されています。この設定により、レシピ詳細ページをPartial Prefetchingへ段階的に切り替えます。

検証条件

主な検証では、次の3条件を比較しました。

条件 Next.js Partial Prefetchingを適用する場所 データ取得中の表示
条件A 16.2.11 適用しない なし
条件B 16.3.1 レシピ詳細ページ なし
条件C 16.3.1 レシピ詳細ページ スケルトン表示

実験1では条件Aと条件Bを比較し、クリック前の取得を減らしたときに通信と画面遷移がどう変わるかを確認しました。実験2では条件Bと条件Cを比較し、URL固有のデータを待つ間にApp Shellが画面にどう現れるかを確認しました。

以下で扱う条件B・Cと補足検証の先読み通信は、Next.js 16.3.1で観測したものです。内部的な通信の形は、今後のバージョンで変わる可能性があります。

OISHYの既存構成では、Next.jsのstandalone serverをECSで動かし、その前段にCloudFrontとALBを置いています。今回の計測もこの配信経路で行いました。

計測方法

  • 対象:同じ10件のレシピを、それぞれ3回ずつ計測
  • 操作:トップページを新しいタブで開き、対象リンクが画面内に入る位置までスクロール。5秒待ってからクリックし、クリック前の先読み通信とレシピタイトルが表示されるまでの通信・時間を記録
  • ブラウザ条件:画面サイズは1280×900、回線速度の制限はなし。ブラウザ側のHTTPキャッシュは計測のたびに無効化し、スクロール位置とクリックまでの待ち時間を統一
  • 集計:各レシピの3回の中央値を求めたあと、10件の中央値を代表値として使用。通信量にはChrome DevTools ProtocolのNetwork.loadingFinished.encodedDataLengthを使い、ブラウザが受信したデータ量に近い値を比較

実験1:クリック前の取得を減らすと、通信と遷移はどう変わるか

最初に、Next.js 16.2.11の条件Aと、16.3.1へ更新してレシピ詳細ページをPartial Prefetchingへ切り替えた条件Bを比較しました。この比較にはNext.js自体の更新も含まれるため、Partial Prefetchingだけの効果ではなく、OISHYで16.3.1への更新と機能導入を行った前後差として扱います。

ここでいうRSC(React Server Components)データは、Next.jsがクライアント側の画面を更新するために送るデータです。

指標(中央値) 条件A(変更前) 条件B(レシピ詳細ページに適用)
クリック前のRSCリクエスト 75 6
クリック前の転送量 約319KB 約20KB
クリック後のRSCリクエスト 0 1
クリック後の転送量 0KB 約20KB
クリック前後の合計リクエスト 75 7
クリック前後の合計転送量 約319KB 約38KB

クリック前のリクエスト数は約92%、転送量は約94%減りました。クリック後には、選択したレシピのRSCリクエストが1件発生しています。それを含めた合計転送量も約88%減っており、クリック前の通信がそのまますべてクリック後へ移ったわけではありませんでした。

変更前は、画面内に並ぶ多数のレシピについてURL固有のRSCデータを取得していました。変更後は、共通部分を先読みし、選択したレシピのデータをクリック後に取得しています。今回の画面では、クリックされなかったレシピへの先読みが減ったことが、通信量の差として大きく現れました。

次の画像は1回の計測例で、表の数値は反復計測から求めた代表値です。

条件Bでのクリック前のNetwork記録の例

一方、クリックからレシピタイトルが表示されるまでの代表値は、変更前が約44ms、変更後が約82msで、どちらも100ms未満でした。URL固有のデータをクリック後に取得するようになったことと整合しますが、今回の条件では目視できるほど長い待ち時間にはなりませんでした。大きく変わったのは、完成画面が出るまでの見た目よりも、クリック前に取得するデータの量でした。

なお、この結果は回線速度を制限しない環境でのものです。Partial Prefetchingは、クリック前の転送量を減らす代わりに、URL固有データの取得をクリック後へ移す仕組みです。低速な回線では、クリック後の待ち時間が今回より長くなる可能性がある一方、クリックされないリンクへの先読みを抑える効果は大きくなるため、有効に働くかどうかは通信環境によってトレードオフになり得ます。

実験2:URL固有のデータを待つ間に何が見えるか

Partial Prefetchingでは、URL固有のデータがクリック時点で揃っていなければ、クリック後にデータの取得を待つ時間が生じます。その間にApp Shellがどのように現れるかを見るため、条件Cではレシピデータの取得中に表示するスケルトンをloading.tsxとして定義しました。

loading.tsx自体はNext.js 16.3の新機能ではありません。同じルート階層のページをSuspense境界で囲み、定義したUIをデータの準備中に表示する仕組みです。Partial Prefetchingでは、この代替表示を含むApp Shellが先読みの対象になります。

// app/recipes/[id]/loading.tsx
export default function RecipeDetailLoading() {
  return <RecipeDetailSkeleton />;
}

スケルトンとは、完成後のレイアウトに近い枠を先に表示し、データを待っている場所を示すUIです。今回のレシピ詳細ページでは、次のスケルトンがApp Shellとして先に表示される部分になります。

レシピ詳細ページのスケルトン表示例

30回中25回はスケルトンを経由せず、完成したレシピ詳細が直接表示されました。スケルトンが先に現れたのは5回で、クリックから14〜22msで表示され、レシピタイトルより約68〜856ms先行しました。

この結果から、スケルトンは毎回挟まる中間画面ではなく、レシピ固有のデータがクリックまでに揃わなかった場合だけ、遷移先の枠組みとして先に表示されることが分かりました。

また、条件Cの通信を追加で確認すると、条件Bとは異なる挙動が見つかりました。条件Bではレシピ固有のデータをクリック後に取得していた一方、条件Cの補完計測では、10回中4回で複数のレシピ固有データをクリック前に取得するRSC通信が並びました。

条件Cでクリック前に観測したレシピ固有データの先読み

この違いがPartial Prefetchingの適用範囲と関係するのかを確認するため、追加計測しました。

補足:適用範囲によって先読みの形が異なった

条件Cでは、レシピ詳細ページだけにprefetch = 'partial'を設定していました。

prefetch = 'partial'は、設定したページより上位のルート階層まで同じ設定にするものではありません。Next.js 16.3.1の実装では、それぞれのルート階層が、その階層で指定された設定かアプリ全体の既定値を参照します。

この挙動を調べる中で、段階導入時の先読みを扱うNext.js 16.3.1の公式テストを見つけました。このテストにも、動的なページだけをPartialにし、未設定の上位階層を従来の方法で扱う構成で、リンク先固有のデータまで先読みする例があります。

そこで、条件Cのloading.tsxとページ側の設定を残したまま、アプリ全体の既定値をPartialにするpartialPrefetching: trueだけを追加しました。

// next.config.ts
const nextConfig = {
  cacheComponents: true,
  partialPrefetching: true,
};

同じ導線を10回計測した結果は次のとおりです。

Partial Prefetchingの適用範囲 URL固有データの先読み 共有部分の先読み
レシピ詳細ページのみ 10回中4回で観測 あり
アプリ全体 10回中0回 あり

同じloading.tsxを残したまま適用範囲を変えると、今回の導線ではURL固有データの先読みが10回中4回から0回になりました。一方、App Shellを構成する共有部分の先読みは続いていました。Next.js 16.3.1の実装と公式テストを合わせると、今回観測した通信にはPartial Prefetchingの適用範囲が関係していたと考えられます。

まとめ

Partial PrefetchingをOISHYのレシピ一覧で試したところ、16.3.1への更新と機能導入後、クリック前のRSC転送量が約94%減り、クリック後を含む合計でも約88%減りました。

一方、変更前からレシピタイトルまでの表示は約44msと十分に速く、回線速度を制限しない今回の環境では、通信量ほど大きな見た目の差はありませんでした。スケルトンを含むApp Shellは、URL固有のデータがクリックまでに揃わなかった場合だけ、完成したページより先に表示されました。

また、レシピ詳細ページだけに適用した場合と、アプリ全体に適用した場合では、クリック前の先読みも異なりました。ページ単位で試せる機能ではありますが、今回の検証では適用範囲も実際の通信に関係していました。

Partial Prefetchingは、商品・記事・レシピの一覧のように、同じ種類の詳細ページへのリンクが多く、その一部だけがクリックされる画面を想定しています。クリックされないURL固有データの先読みを抑えながら、データが間に合わない場合にはApp Shellを先に表示する仕組みです。今回のOISHYでは、先読み通信量には大きな差が出た一方、見た目の差は小さいという結果でした。

参考文献