Next.jsで取得元のデータを更新しても、画面には以前の値が表示されることがあります。このとき、サーバーで取得結果を再利用しているのか、ブラウザが以前の画面を再利用しているのかを、表示だけから判断するのは難しいものです。
そこで、価格・描画ID・取得ID・取得元へのアクセス回数を表示する実験リポジトリを作りました。操作前後の値を比較し、どの処理が実行され、どの結果が再利用されたかを確認できます。
リポジトリはこちらです。
taako-502/nextjs-cache-playground
実験する範囲
この記事はNext.js 16.4.5で、cacheComponents: trueとpartialPrefetching: trueを設定した構成を対象にしています。Cache Componentsを使うアプリの基本的な挙動を、次の5つの実験で確認します。
| 実験 | 確認すること |
|---|---|
| use cache | 関数の結果の再利用、引数による分離、タグによる失効 |
| cacheLife | 時間経過とアクセスによるキャッシュの更新 |
| PPR | 静的な部分と動的な部分が届く順序 |
| 保存後の更新 | Server Actionで保存し、更新後の値を読み取る流れ |
| 画面遷移とキャッシュ | 事前取得、クリック後の取得、戻る操作での再利用 |

起動方法と実験の構成
Node.jsとYarnのバージョンはVoltaで固定しています。Voltaを導入した環境で、次のコマンドを実行します。
git clone https://github.com/taako-502/nextjs-cache-playground.git
cd nextjs-cache-playground
yarn install --immutable
yarn lab
起動後、ブラウザでhttp://localhost:3201を開きます。
yarn labは、価格を管理する取得元APIと、Next.jsアプリの2つを起動します。Next.jsはnext buildとnext startによる本番モードで動作します。開発モードではHMRなどが挙動に影響するため、この記事の実験には本番モードを使います。
取得元APIは、商品キーごとに価格とアクセス回数をメモリ上で管理します。Next.jsから独立したプロセスなので、取得元の価格とキャッシュされた価格を分けて観測できます。再起動すると取得元の状態も初期化されます。
画面の数値をどう読むか
use cacheの画面では、主に次の値を比較します。
| 項目 | 意味 |
|---|---|
| 表示価格 | Next.jsが取得結果として返した価格 |
| 取得元の価格 | 取得元APIが現在保持している価格 |
| 描画ID | 実験ページの動的な処理が実行されたときに発行するID |
| 取得ID | 取得元APIでデータを読み取ったときに発行するID |
| 取得元へのアクセス回数 | データの読み取り処理が実行された回数 |
たとえば、描画IDが変わって取得IDが変わらなければ、ページの処理は実行され、データには以前の取得結果が使われています。同じ価格が返った場合も、取得IDが変わっていれば再取得が行われたと判断できます。
1. use cacheで関数の結果を再利用する
最初に、use cacheの画面を開きます。価格を取得する関数は次のようになっています。
export async function cachedProduct(key: string) {
'use cache';
cacheLife({ stale: 300, revalidate: 3600, expire: 7200 });
cacheTag(tag(key));
return readSource(key, { cache: 'no-store' });
}
use cacheによって、この関数の結果を再利用します。引数のkeyはキャッシュキーの構成要素になるので、商品キーが変わると別の結果を保持します。cacheTagは、後から関連するキャッシュを失効させるためのラベルです。
内側のデータ取得にはno-storeを指定しています。それでも外側の関数の結果を再利用できる間は、関数内のデータ取得まで進みません。
取得元だけを更新する
初期状態では、表示価格と取得元の価格はどちらも100円です。
- 「取得元を +100円」を押します。
- 「再リクエスト」を押します。
- 価格とIDを比較します。
取得元の価格は200円になっていますが、表示価格は100円のままです。描画IDは変わり、取得IDは変わりません。

取得元の更新だけでは、use cacheで保持した結果は失効しません。この操作によって、ページの処理が再実行されることと、データを再取得することを区別できます。
なお、この画面の「再リクエスト」は、ページのHTMLをHTTPで取得して観測結果を読み取る操作です。ブラウザのRouter Cacheを通さず、サーバー側のキャッシュを確認します。
タグを失効させる
続けて「タグを即時失効」→「再リクエスト」の順に操作します。
表示価格が200円になり、取得IDも変わります。タグの失効後に取得元へアクセスし、新しい結果を返したことがわかります。

この操作では、Route HandlerからrevalidateTag(tag, { expire: 0 })を呼んでいます。失効したキャッシュを次に読むときに、新しい値の取得を待ちます。
「タグをSWRで無効化」では、revalidateTag(tag, 'max')を呼びます。SWRはstale-while-revalidateの略で、古い値を返しながらバックグラウンドで新しい値を取得する方法です。無効化後のアクセスと、更新完了後のアクセスを比較すると違いを観測できます。
2. cacheLifeで時間経過による更新を確認する
cacheLifeでは、キャッシュの時間に関する設定を指定します。
cacheLife({ stale: 0, revalidate: 5, expire: 10 });
3つの値の役割は次のとおりです。
| 値 | 役割 |
|---|---|
| stale | クライアント側で、サーバーに更新確認せず再利用できる期間 |
| revalidate | サーバー側で再検証が必要になるまでの期間 |
| expire | サーバー側で古い内容を返し続けられる期間の上限 |
実験では、価格を取得した直後に取得元を更新します。キャッシュ生成から5秒以内に再リクエストすると以前の価格が返り、5秒経過後に再リクエストすると新しい価格を取得します。
時間の基準はキャッシュが生成された時点です。取得元の価格を書き換えても、キャッシュの期限はリセットされません。また、時間が経過しただけで取得元へ定期アクセスする仕組みではなく、その後のアクセスが更新のきっかけになります。
この環境で観測できる範囲
Next.js 16.4.5の標準メモリハンドラーを使う本番環境では、revalidate経過後のエントリーが破棄されます。この実験では、5秒後のアクセスで新しい値を待つ挙動を確認します。
公式ドキュメントで説明される再検証の考え方と、特定のキャッシュハンドラーでの実測を区別して読む必要があります。この結果を、すべての実行環境にそのまま当てはめることはできません。
また、expireの10秒より先に破棄されるため、この構成ではexpire単独の効果を分離できません。HTTPでサーバー側を観測する画面なので、staleによるブラウザ側の再利用も、この実験の検証対象には含めていません。
実装の根拠は対象バージョンの標準キャッシュハンドラーを参照してください。
3. PPRで静的な部分と動的な部分を組み合わせる
PPRはPartial Prerenderingの略です。ページの静的な部分を事前に生成し、リクエスト時の処理が必要な部分と組み合わせます。
実験画面には、静的な領域、キャッシュされた共通データ、リクエストごとに取得する動的データを配置しています。動的データの取得には意図的に1.8秒の遅延を入れ、Suspenseの境界で読み込み中の表示を出します。

動的なデータが届くと、読み込み中の部分が実際の内容に置き換わります。

この2枚から、遅いデータ取得の完了前に、ページの一部を表示できることがわかります。再読み込み時には、キャッシュされた共通データと動的データの取得IDも比較できます。
PPRでは、ページ内の各部分をいつ生成し、どの順序で届けるかに注目します。
4. 保存後にupdateTagで新しい値を読み取る
次は、利用者がデータを保存した場面です。保存後の画面に新しい値を表示するため、Server Actionの中で取得元を更新し、タグを失効させてから同じ取得関数を呼び直します。
処理の中心は次の順序です。入力検証などは省略しています。
const before = await cachedProduct(key);
// 取得元APIへ新しい価格を保存する
const response = await fetch(`${origin}/price?key=${key}&price=${raw}`, {
method: 'POST',
cache: 'no-store',
});
if (!response.ok) return { error: '保存できませんでした。' };
updateTag(tag(key));
const after = await cachedProduct(key);
return { before, after };
updateTagはServer Actionsで使うAPIです。指定したタグのキャッシュを即時失効させ、次の読み取りでは新しい値の取得を待ちます。保存した本人が自分の変更をすぐ読める、read-your-own-writesという用途に対応します。
実験では、100円の商品に250円と入力して保存します。画面には保存前の100円と、保存後に取得関数から読み直した250円が並びます。取得IDの変化からも、再取得を確認できます。

表示する保存後の価格には、取得元から読み直した結果を使っています。そのため、保存処理とキャッシュ失効を組み合わせた流れを確認できます。
5. 画面遷移とブラウザ側の再利用を確認する
最後に、商品一覧と商品詳細を行き来します。この実験の価格取得はno-storeです。取得IDと通信記録を使って、サーバー側のデータ取得とブラウザ側の画面再利用を見分けます。
操作手順は次のとおりです。
- 商品一覧を開き、詳細リンクの事前取得を待ちます。
- 「商品詳細へ」を押します。
- 「一覧に戻る(履歴)」を押します。
- 一覧の取得IDを最初の表示と比較します。
- 「再読み込み」を押し、取得IDを再び比較します。
この構成で確認する結果は次のとおりです。
| 操作 | 観測する結果 |
|---|---|
| 詳細リンクの事前取得 | 詳細の静的な部分を取得。詳細の価格はまだ取得しない |
| 詳細へ移動 | 動的な価格を取得し、取得元へのアクセスが増える |
| 履歴で一覧へ戻る | 最初の一覧と同じ取得IDを表示し、一覧の取得元へのアクセスは増えない |
| 一覧を再読み込み | 新しい取得IDになり、取得元へのアクセスが増える |

Next.jsのクライアント側には、画面遷移に使うServer Componentsの描画結果を保持する仕組みがあります。通信欄のRSCはReact Server Componentsの略です。履歴で戻ったときは、以前の画面の内容を再利用できます。
no-storeを指定したデータ取得も、サーバー側の処理が実行される場面で効きます。ブラウザが以前の画面を復元した場合は、データ取得処理そのものが実行されないことがあります。
画面上の通信記録には、RSCリクエストの時刻とパスを表示します。自動テストではさらにリクエストヘッダーと取得元へのアクセス回数を確認し、事前取得とクリック後の取得を区別しています。
キャッシュの挙動はどうテストするか
リポジトリでは、検証する対象に合わせてテストを分けています。
| テスト | 確認する内容 |
|---|---|
| UT | 計測用APIの価格更新、キーの分離、取得回数 |
| HTTP結合テスト | Next.jsを通したキャッシュの再利用、タグ失効、時間経過による更新 |
| ブラウザE2E | PPRの表示、保存操作、事前取得、履歴移動、再読み込み |
yarn playwright install chromium
yarn test:unit
yarn test
yarn typecheck
Next.jsのキャッシュを確認するテストでは、実際の本番ビルドとHTTP通信を使います。キャッシュAPIをモックするとフレームワーク側の挙動を確認できないため、価格・取得ID・アクセス回数を観測して検証します。
画面遷移にはブラウザ側の状態が関係するので、Playwrightで実際にリンクやボタンを操作します。保存のテストでも、Server Actionの実行、取得元の更新、画面への反映を確認しています。
今回の実験で扱わないこと
対象はローカルの単一Next.jsプロセスです。CDNや複数インスタンス間のキャッシュ共有、use cache: remote・use cache: private、ブラウザのHTTPキャッシュは別途検証が必要です。
また、router.refresh()とタグ失効を比較する実験は、現在の画面には含めていません。cacheLifeの3つの値も、それぞれの効果をすべて独立して実証する構成にはしていません。
このリポジトリでは、取得元の更新、サーバー側での結果の再利用、ブラウザ側での画面の再利用を、操作と観測値から順に確認できます。手元で試すときは、価格に加えてIDとアクセス回数がどう変わったかを追ってみてください。

コメントを残す