🚀

Next.js App Router 知られざるClient-side Cacheの仕様

2023/05/27に公開

前回、App Routerの遷移の仕組みと実装についてまとめました。

https://zenn.dev/akfm/articles/next-app-router-navigation

今回はこれの続編として、App RouterのClient-side Cacheの仕様や実装についてまとめようと思います。まだドキュメントに記載のない仕様についても言及しているので、参考になる部分があれば幸いです。

App Routerのcache分類

App Routerは積極的にcacheを取り入れており、cacheは用途や段階に応じていくつかに分類することができます。まずはそのcacheの分類を確認してみましょう。

Request Deduping

Request Dedupingはレンダリングツリー内で同一データのGETリクエストを行う際に、自動でまとめてくれる機能です。


nextjs.org/docsより

デフォルトでサポートしているのはfetchのみですが、Reactが提供するcacheを利用することでDBアクセスやGraphQLでも同様のcacheを実現することができます。

https://nextjs.org/docs/app/building-your-application/data-fetching/caching#react-cache

これはNext.jsではなくReact側でfetchを拡張[1]することで行なっているようです。

以下の記事がRequest Dedupingについてより詳しく解説されているので、興味のある方はぜひご一読ください。

https://zenn.dev/cybozu_frontend/articles/next-caching-dedupe

Caching Data

Caching DataはRequest Deduping同様、主にfetchによるGETリクエストが対象ですが、Request Dedupingは同一レンダリングツリー内で有効になるcacheなのに対し、Caching DataはCDN上などのlocation単位で有効なcacheです。Request Dedupingは1リクエストに対し、Caching Dataは複数リクエスト・複数ユーザーに対し有効と考えるとわかりやすいかと思います。


nextjs.org/docsより

これはReact側ではなく、Next.js側でfetchを拡張[2]することで実現しているようです。

Caching DataはrevalidateオプションやfetchCacheを指定することで有効になります。
こちらも先述の記事を書かれたmugiさんがシリーズ的に解説されているので、興味のある方はぜひご一読ください。

https://zenn.dev/cybozu_frontend/articles/next-caching-revalidate

CDN cache

App Routerではfetchのcacheの話に目が行きがちですが、従来通り静的ファイルもCDN cacheとして扱えるよう設計されています。

静的ファイル置き場であるpublicフォルダはCDN cache可能なファイルとなることが想定され、VercelにデプロイするとデフォルトでCDN cacheされます。また、Static Site Generation (SSG)した結果も同様に静的ファイルになるので、これらもCDNにcacheすることが可能です。

Client-side caching

上記3つのcacheはユーザーからするとサーバー(CDN)側のcacheです。対して、Client-side cachingは、文字通りクライアントサイドのインメモリなcacheです。Client-side cachingは、レンダリングしたReact Server Componentsをcacheします。インメモリなのでリロードやMPA遷移を挟むと消えてしまいますが、App Router間の遷移においては有効です。

このcacheについてはNext.jsのドキュメントではあまり詳細に語られておらず、筆者が確認した限りだと以下くらいの情報しかありません。

The new router has an in-memory client-side Cache that stores the rendered result of Server Components (payload). The cache is split by route segments which allows invalidation at any level and ensures consistency across concurrent renders.

As users navigate around the app, the router will store the payload of previously fetched segments and prefetched segments in the cache.

This means, for certain cases, the router can re-use the cache instead of making a new request to the server. This improves performance by avoiding re-fetching data and re-rendering components unnecessarily.
<以下Deepl訳>
新しいルーターには、Server Componentsのレンダリング結果(ペイロード)を保存するインメモリ・クライアントサイド・キャッシュがあります。キャッシュはルートセグメントで分割されているため、どのレベルでも無効化でき、同時レンダリング時の一貫性を確保できます。

ユーザーがアプリ内を移動すると、ルーターは以前にフェッチしたセグメントとプリフェッチしたセグメントのペイロードをキャッシュに保存します。

これは、特定のケースにおいて、ルーターがサーバーに新たなリクエストを行う代わりにキャッシュを再利用できることを意味します。これにより、不必要なデータの再取得やコンポーネントの再レンダリングを回避し、パフォーマンスを向上させることができます。

上記内容は「キャッシュの無効化」の説明や「特定のケース」の定義など、いくつかの点で説明が省略されています。以降はこのClient-side Cacheについて、実装を追いながらより詳細な仕様について確認していきたいと思います。

Client-side Cacheの保存と利用

Client-side CacheはApp Router的にどう実装されているのでしょう?Client-side Cacheは内部的にはprefetchCacheと呼ばれています。文字通りprefetch時に格納されるのですが、実はprefetch以外でも格納されます。なぜprefetchCacheという命名なのかは不明ですが、開発中に仕様が変わり続けて残ってしまったのかもしれません。詳細は後述しますので、まずはprefetchCacheの実装を確認してみます。

前回の記事でも説明したように、App Routerは内部的にuseReducerベースで作成したStateで多くの状態を管理しており、prefetchCacheもそのStateの一部として管理されています。具体的にはprefetchCache: Map<string, PrefetchCacheEntry>dataにprefetchのPromiseごと格納しています。

https://github.com/vercel/next.js/blob/afddb6ebdade616cdd7780273be4cd28d4509890/packages/next/src/client/components/router-reducer/router-reducer-types.ts#L209-L215

このcacheは遷移時に発火するnavigateアクションのreducer内で読み取られます。prefetchCacheという命名が紛らわしいのですが、cacheがなかった場合、fetchを行ってこのcacheを作成するなど、App Routerの遷移には必ずprefetchCacheが必要になります。

https://github.com/vercel/next.js/blob/afddb6ebdade616cdd7780273be4cd28d4509890/packages/next/src/client/components/router-reducer/reducers/navigate-reducer.ts#L229

ちなみにPromiseから値を同期的に読み取るreadRecordValueは、Promiseを拡張して行なっているようです。(これも少々行儀が悪い気がしますが、、、

https://github.com/vercel/next.js/blob/afddb6ebdade616cdd7780273be4cd28d4509890/packages/next/src/client/components/router-reducer/create-record-from-thenable.ts

追記: 指摘いただいたところによると、これはどうやらReactのPromise 1st Class Supportの仕様が由来した実装のようです。

Client-side Cacheの種別

さて、Client-side Cacheには内部的にauto/full/temporaryの3種類が存在します。

https://github.com/vercel/next.js/blob/afddb6ebdade616cdd7780273be4cd28d4509890/packages/next/src/client/components/router-reducer/router-reducer-types.ts#L158-L169

コメントに説明があるのでDeepl翻訳してみます。

  • auto - ページが動的な場合は、ページデータを部分的にプリフェッチし、静的な場合はページデータを完全にプリフェッチします。
  • full - ページデータを完全にプリフェッチする。
  • temporary - これはnext/linkprefetch={false}が使われているときや、プログラムでルートをプッシュするときに使用されます。

ここで言う動的なページとはDynamic Renderingと呼ばれる、リクエストが来るまでレンダリングできないページを指します。App RouterはDynamic Renderingなページかどうかをno-storeなfetchがあるかどうかや、Dynamic Functionscookiesheadersなど)が使われているかどうかで判断します。App Routerではnext build時に一度各pageコンポーネントを実行してるので、その時に判断されるものと考えられます。

これらのcache種別は基本的にLinkコンポーネントのprefetchと対応関係にあります。

  • auto: prefetch={undefined}
  • full: prefetch={true}
  • temporary: prefetch={false}

以下の簡単なデモページで挙動を確認してみましょう。


デモページ

Linksにあるリンクはそれぞれ、cache種別ごとにページリンクになります。autoのみDynamic Renderingかどうかで分岐があるので、2ページ用意しています。

URL prefetch Dynamic Rendering cache種別
/cache_auto/static undefined × auto
/cache_auto/dynamic undefined ○(next/headersを利用) auto
/cache_full true × full
/cache_temporary false × temporary


画面内に要素があるとprefetchが発火する

viewport内にLinkが入ってくるとprefetchアクションが発火し、リンク先ページのレンダリング結果を取得しようと試みます。prefetch={false}/cache_temporaryは当然prefetchされないので他の3ページのprefetchが確認できます。


レンダリング結果がflightで送られてくる

/cache_auto/staticのレスポンスBodyを確認するとFlightが確認できます。Flightについては前回の記事でも言及していますが、React Server Components(RSC)をレンダリングした結果を表現する、独自のデータフォーマットです。


Dynamic Rendering部分がレンダリングされないので、他ページよりレンダリング結果が減っている

/cache_auto/dynamicページの実装は以下のようになっています。

// /src/app/cache_auto/dynamic/page.tsx
export default async function Page() {
  const res = await fetch("https://dummyjson.com/products");
  await timer();
  const data = await res.json();
  // Dynamic Functions
  const headersList = headers();
  console.log("headersList", headersList);

  return (
    ...
  );
}

pageがDynamic Functions(next/headers)に依存しているため、このページはDynamic Renderingと判定されます。

以下は実際のレスポンスBodyです。ページの内容的には/cache_auto/staticより多いはずですが、Dynamic Renderingはprefetch時点ではレンダリングされないため、/cache_auto/dynamicの方がレンダリング結果が少ないことが確認できます。

1:HL["/_next/static/css/2a761abac1cc65a8.css",{"as":"style"}]
0:[[["",{"children":["cache_auto",{"children":["static",{"children":["__PAGE__",{}]}]}]},"$undefined","$undefined",true],"$L2",[[["$","link","0",{"rel":"stylesheet","href":"/_next/static/css/2a761abac1cc65a8.css","precedence":"next"}]],["$L3",null]]]]
4:I{"id":"663","chunks":["272:static/chunks/webpack-6cd0790319613630.js","602:static/chunks/c3978839-04849c4dd0c49610.js","269:static/chunks/269-211c86a5f099d56d.js"],"name":"","async":false}
5:I{"id":"6712","chunks":["272:static/chunks/webpack-6cd0790319613630.js","602:static/chunks/c3978839-04849c4dd0c49610.js","269:static/chunks/269-211c86a5f099d56d.js"],"name":"","async":false}
6:I{"id":"863","chunks":["863:static/chunks/863-a23d5de87448ddb7.js","919:static/chunks/app/cache_auto/dynamic/page-d873c36f84943bee.js"],"name":"","async":false}
2:[["$","html",null,{"lang":"en","children":["$","body",null,{"children":["$","div",null,{"className":"flex min-h-screen flex-col items-center justify-between p-24","children":["$","div",null,{"className":"w-full max-w-5xl","children":["$","$L4",null,{"parallelRouterKey":"children","segmentPath":["children"],"error":"$undefined","errorStyles":"$undefined","loading":"$undefined","loadingStyles":"$undefined","hasLoading":false,"template":["$","$L5",null,{}],"templateStyles":"$undefined","notFound":"$undefined","notFoundStyles":"$undefined","asNotFound":"$undefined","childProp":{"current":["$","$L4",null,{"parallelRouterKey":"children","segmentPath":["children","cache_auto","children"],"error":"$undefined","errorStyles":"$undefined","loading":"$undefined","loadingStyles":"$undefined","hasLoading":false,"template":["$","$L5",null,{}],"templateStyles":"$undefined","notFound":"$undefined","notFoundStyles":"$undefined","asNotFound":"$undefined","childProp":{"current":["$","$L4",null,{"parallelRouterKey":"children","segmentPath":["children","cache_auto","children","static","children"],"error":"$undefined","errorStyles":"$undefined","loading":"$undefined","loadingStyles":"$undefined","hasLoading":false,"template":["$","$L5",null,{}],"templateStyles":"$undefined","notFound":"$undefined","notFoundStyles":"$undefined","asNotFound":"$undefined","childProp":{"current":[["$","main",null,{"children":[["$","h1",null,{"className":"mb-4 text-3xl font-extrabold text-gray-900 dark:text-white md:text-5xl lg:text-6xl","children":[[["$","span",null,{"className":"text-transparent bg-clip-text bg-gradient-to-r to-emerald-600 from-sky-400","children":"`app`"}]," "],"cache auto static"]}],["$","p",null,{"className":"text-lg font-normal text-gray-500 lg:text-xl dark:text-gray-400","children":"This is an example page."}],["$","div",null,{"className":"mt-10","children":[["$","h2",null,{"className":"mb-4 text-xl font-extrabold text-gray-900 dark:text-white md:text-4xl lg:text-4xl","children":"Links"}],["$","ul",null,{"className":"list-disc pl-5","children":[["$","li",null,{"children":["$","$L6",null,{"href":"/cache_auto/static","className":"underline","children":"/cache_auto/static"}]}],["$","li",null,{"children":["$","$L6",null,{"href":"/cache_auto/dynamic","className":"underline","children":"/cache_auto/dynamic"}]}],["$","li",null,{"children":["$","$L6",null,{"href":"/cache_full","className":"underline","prefetch":true,"children":"/cache_full"}]}],["$","li",null,{"children":["$","$L6",null,{"href":"/cache_temporary","className":"underline","prefetch":false,"children":"/cache_temporary"}]}]]}]]}]]}],null],"segment":"__PAGE__"},"styles":[]}],"segment":"static"},"styles":[]}],"segment":"cache_auto"},"styles":[]}]}]}]}]}],null]
3:[[["$","meta",null,{"charSet":"utf-8"}],["$","title",null,{"children":"Create Next App"}],["$","meta",null,{"name":"description","content":"Generated by create next app"}],null,null,null,null,null,null,null,null,["$","meta",null,{"name":"viewport","content":"width=device-width, initial-scale=1"}],null,null,null,null,null,null,null,null,null,null,[]],[null,null,null,null],null,null,[null,null,null,null,null],null,null,null,null,[null,[["$","link",null,{"rel":"icon","href":"/favicon.ico","type":"image/x-icon","sizes":"any"}]],[],null]]

/cache_auto/staticのFlight

0:[["children","cache_auto",["cache_auto",{"children":["dynamic",{"children":["__PAGE__",{}]}]}],"$L1",[[],["$L2",null]]]]
3:I{"id":"663","chunks":["272:static/chunks/webpack-6cd0790319613630.js","602:static/chunks/c3978839-04849c4dd0c49610.js","269:static/chunks/269-211c86a5f099d56d.js"],"name":"","async":false}
4:I{"id":"6712","chunks":["272:static/chunks/webpack-6cd0790319613630.js","602:static/chunks/c3978839-04849c4dd0c49610.js","269:static/chunks/269-211c86a5f099d56d.js"],"name":"","async":false}
1:["$","$L3",null,{"parallelRouterKey":"children","segmentPath":["children","cache_auto","children"],"error":"$undefined","errorStyles":"$undefined","loading":"$undefined","loadingStyles":"$undefined","hasLoading":false,"template":["$","$L4",null,{}],"templateStyles":"$undefined","notFound":"$undefined","notFoundStyles":"$undefined","asNotFound":"$undefined","childProp":{"current":["$","$L3",null,{"parallelRouterKey":"children","segmentPath":["children","cache_auto","children","dynamic","children"],"loading":"loading...","loadingStyles":[],"hasLoading":true,"error":"$undefined","errorStyles":"$undefined","template":["$","$L4",null,{}],"templateStyles":"$undefined","notFound":"$undefined","notFoundStyles":"$undefined","childProp":{"current":null,"segment":"__PAGE__"}}],"segment":"dynamic"},"styles":[]}]
2:[[["$","meta",null,{"charSet":"utf-8"}],["$","title",null,{"children":"Create Next App"}],["$","meta",null,{"name":"description","content":"Generated by create next app"}],null,null,null,null,null,null,null,null,["$","meta",null,{"name":"viewport","content":"width=device-width, initial-scale=1"}],null,null,null,null,null,null,null,null,null,null,[]],[null,null,null,null],null,null,[null,null,null,null,null],null,null,null,null,[null,[["$","link",null,{"rel":"icon","href":"/favicon.ico","type":"image/x-icon","sizes":"any"}]],[],null]]

/cache_auto/dynamicのFlight


/cache_fullのFlight

/cache_full/cache_auto/staticと特段変わった様子はないようです。fullの大きな違いはClient-side Cacheの有効時間にあるためです。

Client-side Cacheの有効期限

cacheというからには当然ながら有効期限があり、前述のcacheの種別によって有効期限の仕様が異なります。内部的にはこれはステータスとして管理されており、以下のenumで定義されています。

https://github.com/vercel/next.js/blob/afddb6ebdade616cdd7780273be4cd28d4509890/packages/next/src/client/components/router-reducer/get-prefetch-cache-entry-status.ts#L6-L11

上記よりcacheのステータスは、以下のように分類されることがわかります。

  • fresh: 新しいcache
  • reusable: 再利用可能なcache
  • stale: 古いcache
  • expired: 破棄されるべきcache

このステータスは直後に定義されている関数によって内部的に判定されます。

https://github.com/vercel/next.js/blob/afddb6ebdade616cdd7780273be4cd28d4509890/packages/next/src/client/components/router-reducer/get-prefetch-cache-entry-status.ts#L18-L39

内部的にはprefetchTimeとなっていますが、prefetch={false}temporary)の時はprefetchは発行されないので、実際にはfetchからの経過時間となります。上記関数より、cacheは種別・取得からの経過時間・lastUsed(cacheの最後の利用時間)によって以下のようなステータス分類が行われていることがわかります。

時間判定 auto full temporary
prefetch/fetchから30秒以内 fresh fresh fresh
lastUsedから30秒以内 reusable reusable reusable
prefetch/fetchから30秒~5分 stale reusable expired
prefetch/fetchから5分以降 expired expired expired

cacheのステータスによってprefetch/fetchの再取得には差異があり、実際に確認してみると以下のような差異がありました。

  • fresh, reusable: prefetch/fetchを再発行せず、cacheを再利用する
  • stale: Dynamic Rendering部分だけ遷移時に再fetchを行う
  • expired: prefetchh/fetchを再発行する

Client-side Cacheのrevalidate

しかし、cacheというからには当然任意のタイミングでrevalidateしたいケースが存在するであろうことが想像できます。

筆者が確認した限り、ドキュメントには有効期限の話やrevalidateする方法についての記載は見つけられませんでした。Dynamic Renderingなページでも、freshreusableになりうるため、cacheがあれば再利用されてしまいます。

On-Demand Revalidationの機能なども試してみましたが、これがクリアできるcacheはやはりサーバー側(Caching Data)が対象のようで、Client-side Cacheをクリアすることはできませんでした。

これについてはすでにいくつかissueが立っており、以下のissueが最も盛んに議論されているようでした。

https://github.com/vercel/next.js/issues/42991

上記issueではrouter.refresh()を利用する手段などが提案されており、試したところ、確かにcacheを利用せず再度fetchした内容が反映されている様子が確認できました。ただ、これはスマートな解決策とは言い難いところです。できることならClient-side Cacheの時間を任意に指定できたり、Dynamic Renderingの場合はデフォルトでrouter.refresh()を呼び出してくれたり、任意のタイミングでrevalidateする手段が提供されていることが望ましい気がします。また、おそらくですがrouter.refresh()ではページ全体をrefreshしてしまうので、ドキュメントにあった以下のような部分的なcacheの無効化ができるのはNext.js側だけで、開発者側ではできないのではないかと考えられます。

キャッシュはルートセグメントで分割されているため、どのレベルでも無効化でき

せめて無効化できない時間については仕様の記載が欲しいので、issueを立てました。

https://github.com/vercel/next.js/issues/49431

今後何かしらの対応がされ、ドキュメントも更新されることを願います。

感想

今回はClient-side Cacheに重きを置いて実装や仕様を調査してみました。App Routerの積極的なキャッシュはINPInteraction To Next Paint)などの改善が見込まれるし、個人的には歓迎してる部分も多いです。一方でClient-side Cacheが一定時間保持され続けてしまうことについては、ドキュメントでの説明や対応方法の不足があり、プロダクションで利用するにはかなり大きな制約になると感じています。特にユーザー情報を扱うWebアプリ開発などでは、この手の問題は致命的になり得ます。

App Routerは注目度も高く、すでにstable(安定化)が宣言されたわけですが、利用する開発者側からすると挙動や機能もまだ「安定」していない部分があると筆者は考えています。App Routerはユーザーにとっても開発者にとっても多くのメリットがあるし、非常に魅力的な機能を兼ねているので、上記問題含めさらに多くの改善や発展に期待したいところです。

脚注
  1. https://nextjs.org/docs/app/building-your-application/data-fetching#the-fetch-api ↩︎

  2. https://nextjs.org/docs/app/building-your-application/data-fetching#the-fetch-api ↩︎

Discussion