📝

Next.js Pages Routerのコールドスタート問題の調査記録

に公開

Next.jsで作ったaluepというWebアプリをCloud Runにデプロイしていたのですが、リクエストがコールドスタート時に遅くなるという問題がありました。Cloud Runはコールドスタートが発生するため時間がかかるとは言われていますが、メトリクスを見るとコンテナ起動のレイテンシは約1秒、ページのレスポンスには5 ~ 7秒かかっていました。

この投稿は、どうにかしてCloud Runで時間がかかっていそうな処理を見つけ、改善するまでの記録です。今後似たような調査を行うときに、どれか一つでも役に立つことを願って、やってきたことや関連する情報について詳細に書いてきます。

長くなってしまうので、最初に調査の概要と結果について書きます。

調査の概要と結果

調査した環境は以下のとおりです。

Automatic Static Optimizationが適用されたページにリクエストを送ると、レスポンスに5 ~ 7秒かかってしまう原因について調査しました。コールドスタート時のコンテナ起動のレイテンシが約1秒あるとしても、バックエンド側で処理があまり必要がないと考えていたため、ここまで時間がかかる原因がわかりませんでした。

結果は、_document.tsxにあるMantineからのimportによって、大量のMantineのモジュール解決が発生していることが原因でした。importを削除することで、ホーム画面のレスポンスが 5 ~ 7秒 から 1 ~ 3秒 まで改善しました。

調査の記録

Next.jsのコールドスタート問題を調べている際、Next.jsのGitHub Discussionsにあったコメントをきっかけに詳細な調査を開始しました。そのコメントでは、Next.jsの内部でページを読み込むために使用されているrequirePageという関数に時間がかかっているとありました。これを見たとき、どうやって調査しているんだろう?と思い、自分でも試したくなりました。

調査は、準備をしたり計画を立てるなどのことはせず、場当たり的に行っていました。コードやログや設定を行ったり来たりしながら調査を行っていたのですが、わかりやすくするために順番を整理してまとめています。

Next.jsのコードを読む

まずはNext.jsにリクエストがあったときに具体的にどのような処理が行われているかを把握するために、Next.jsのコードを読みました。Pages Routerを前提としてコードを読んでいます。

対象のアプリはstandaloneモードで実行しており、サーバーの起動にはビルドして生成される .next/standalone/server.js を使用します。このコードの内部で、 server/lib/start-server.ts にあるstartServer関数を呼んでいたので、まずはこのコードを読みました。

startServerにはリクエストハンドラを初期化するコードがあり、router-server.tsinitialize関数がリクエストハンドラを作成して返します。initialize関数で返されるリクエストハンドラの内部では、render-server.tsinitialize関数が呼ばれ、initialize関数はNextServerクラスを返すnext.tscreateServer関数をさらに呼び出し、NextServer.getRequestHandlerで実際のリクエストハンドラを取得します。NextServer.getRequestHandler関数は、更に様々な関数を呼び出して、最終的にはNextNodeServer.handleCatchallRenderRequest関数を呼ぶリクエストハンドラが返されます。

リクエストハンドラを作成するフェーズと、リクエストを実際に処理するフェーズが混ざっているのでわかりにくいのですが、コードを追っていった順番は以下のようになります。

  • start-server.tsstartServer関数
  • router-server.tsinitialize関数
  • render-server.tsinitialize関数
  • next.tscreateServer関数
  • next.tsNextServer.getRequestHandler関数
  • ...
  • next-server.tsNextNodeServer.handleCatchallRenderRequest関数

リクエストがあると、最終的にNextServer.handleCatchallRenderRequest関数が呼ばれ、APIリクエストページコンポーネントのリクエストの分岐があるように見えました。

ここまでで、実際にリクエストがあったときに実行されていそうなパスのコードを見ていきました。ただ、重要そうな箇所しか読んでいなかったので、実際に実行されるコードは異なっている可能性がありますし、所要時間もわかりません。

そこで、実行パスを可視化できないかを考えていました。

実行パスを可視化する

debug関数

Next.jsのコードを読んでいるとき、debug関数が呼ばれている部分がいくつかありました。例えば、router-server.tsのコードの中には、以下のようなコードが存在します。

import setupDebug from 'next/dist/compiled/debug'
// ...
const debug = setupDebug('next:router-server:main')
// ...
debug('invokeRender', req.url, req.headers)

なんとなくデバッグ時に情報を出力するための関数だと思ったので、どうにかしてこれを表示できないかを調べました。debugの実態はここにあるのですが、minifyされており読めたものではありません。何も考えずにChatGPTに投げてみたところ、setupDebugに渡している文字列をDEBUG環境変数に設定すると表示できそうでした。

環境変数を設定してみると、うまく表示されました。ホーム画面へのリクエストを投げてみると、invokeRender '/' ...のようなログが表示されます。これによって、リクエスト時にinvokeRender関数が実行されることがわかります。invokeRender関数は、内部でrender-server.tsinitialize関数を呼び、取得したリクエストハンドラを実行します。

https://github.com/vercel/next.js/blob/3c01e3a9a17e5cc8d060b87e57d30ef544fe5dcd/packages/next/src/server/lib/router-server.ts#L216-L289

// 抜粋
async function invokeRender() {
  debug('invokeRender', req.url, req.headers)

  const initResult = await renderServer?.instance?.initialize(
    renderServerOpts
  )
  await initResult?.requestHandler(req, res)
  return
}

これまで読んできたコードの少なくとも最初の部分については実行されていると確認できました。invokeRenderのログのあとに数秒経過していることから、この処理に時間がかかっていることもわかります。

しかし、改善のためには情報が不足しています。問題を改善するためには、ユーザーが実装しているコードのどの部分に時間がかかっているのかを把握する必要があります。

Sentry Tracing

Next.jsのコードでは、debug関数よりも高い頻度でgetTracer関数というものが使われています。これはNextTraceImplクラスを作成する関数で、このクラスはOpenTelemetryというフレームワークを使用して実装されています。

OpenTelemetry (OTel) は、オブザーバビリティのためのフレームワークのことで、トレース・メトリクス・ログといったテレメトリデータを作成・収集するためのものです。オープンスタンダードで様々なベンダーによってサポートされているため、ベンダーに依存せずにオブザーバビリティを向上させることができます。

OTelには主要なモジュールとしてAPIとSDKがあり、APIはインターフェースのようなもので、SDKは実装です。これらを分離することで、ライブラリはAPIに依存するだけで異なるSDKをサポートすることができます。Node.jsのOTelの実装では、API/SDKの分離をシングルトンやグローバル変数を利用して実現しています。API側でシングルトンやグローバル変数を提供し、SDK側でAPIのシングルトンに実装を登録して、テレメトリデータを作成・収集できるようにしています。

NextTraceImplの実装を見てみると、@opentelemetry/apiからimportされたモジュールを使用しています。Next.jsはOtelのSDKを使用する方法も提供しており、ユーザーがSDKを直接使用したり、ラップされているモジュールを使用できます。

どの処理に時間がかかっているのかを知るために必要なテレメトリデータはトレースです。トレースはコードがどのように実行されているかを把握するのに有用で、スパンというデータで表現されます。スパンは階層構造を持っており親子関係を表現できるので、処理がどの処理から呼び出されているかが明確になります。また、実行時間の内訳を把握しやすく、どの処理にどれだけの時間がかかっているのかも簡単に把握できます。分散システムで特に効果を発揮しますが、それ以外でも処理の流れを掴みやすくなると思います。

Next.jsで簡単にトレース情報を取得できる方法がないかなあと調べていると、Sentryというサービスを見つけました。コマンド一つで良い感じにセットアップしてくれるので、簡単にトレース情報の収集が行えました。

トレースを見てみると、Next.jsのresolve page componentsに約4秒ほどかかっていることがわかりました。このトレースは、findPageComponentsの実行を表しています。これはhandleCatchallRenderRequest関数の中のrender関数から最終的に呼ばれる関数で、コンポーネントのロードなどを行います。

https://github.com/vercel/next.js/blob/3c01e3a9a17e5cc8d060b87e57d30ef544fe5dcd/packages/next/src/server/next-server.ts#L726-L798

// 抜粋
private async findPageComponentsImpl() {
    const components = await loadComponents({
      distDir: this.distDir,
      page: pagePath,
    })

    return {
      components,
    }
}

Next.jsではNEXT_OTEL_VERBOSE=1を設定することで、より詳細なトレースを取得することができますが、Sentryではうまくいきませんでした。デバッグ情報を出力するとトレースは取得されているようなのですが、Sentryのダッシュボードでは確認できませんでした。この情報から、findPageComponentsloadComponentsrenderToResponseWithComponentsの順に実行されていることはわかりましたが、所要時間はわかりません。

debug関数でのログとトレース情報によって、invokeRenderの中で実行されるfindPageComponentsに時間がかかることは確認できました。このことから、どうやらコンポーネントのロードに時間がかかっていそうだということはわかりますが、まだ情報は足りません。

v8 profiler

これまで試してきたdebug関数やgetTracer関数は、あらかじめ意図して設定した箇所の情報が記録されますが、十分な情報が得られませんでした。そういったとき、プロファイリングを行うことで、アプリケーション全体の情報を網羅的に取得することができます。

Node.jsでは、v8のprofilerを使用することでプロファイリングを行うことができます。最も簡単な方法は、node --profでプログラムを実行する方法です。--profオプションを付けるとプロファイルが保存され、それをnode --prof-processに渡すと、時間がかかっている処理の言語や関数のランキングなどが表示されます。ここで出力されるものは概要であり、すべての情報が表示されるわけではないため、まだ情報が足りないです。そこで、フレームグラフを作成することで網羅的な情報を確認することができます。

フレームグラフは関数の所要時間などが視覚化されたグラフのことで、プロファイリングツールで作成することができます。Node.jsだと0xpprof-nodejsなどがあり、どちらも内部的にはv8のprofilerを使用しています。

これらを使用することで各関数の所要時間が確認できると思ったのですが、どうすればCloud Runで使用できるのかがわかりませんでした。そこで、Google Cloudが提供しているCloud Profilerを使用してみました。サポートされている構成のなかにCloud Runはなかったのですが、Cloud Runでも使えるという情報を目にしたので試しました。ちなみにCloud Profilerは内部でpprof-nodejsが使われていそうでした。

Cloud Profilerを試してみたところ、プロファイリングは行われているのですが、あまり正確ではありませんでした。そもそもこれはサンプリングプロファイラなため、特定のリクエストのプロファイリングは難しいのだと思います。

Sentryにもトレースの他にProfilingの機能があるみたいだったので試してみました。こちらはリクエストごとにトレースが表示できるようでしたが、所要時間が正確ではないように見えました。長時間かかっているリクエストも短く表示されてしまいました。

コールドスタート後のリクエストのフレームグラフを作成したかったのですが、正確に作成できる方法を見つけることができませんでした。これができないとなると、Next.jsの内部にログを埋め込む方法しか思いつきません。幸い、これまでの調査で得た情報によって、ある程度のNext.jsの実行パスは頭の中にあります。

ログの埋め込み

Next.jsにログを埋め込むためには、node_modulesを書き換える必要がありますが、ビルド時に再インストールされるため、patch-pacakgeというツールを使用しました。node_modulesを書き換えたあとに実行すると、/patches以下にパッチが作成され、patch-packageコマンドでパッチを適用できるようになります。

これを使ってNext.jsの内部で時間のかかっていそうなコードにあたりをつけてconsole.logを追加していきます。Dockerfileで依存関係をインストールする時点で/patchesディレクトリをコピーするのを忘れていてパッチが適用できていないという問題もありましたが、簡単にログを埋め込むことができました。

主に以下のようにconsole.logを追加していきました。

まずは、これまでの情報から時間がかかっていることがわかっているfindPageComponentsの中で、遅そうなloadComponentsImpl関数にログを追加しました。

https://github.com/vercel/next.js/blob/3c01e3a9a17e5cc8d060b87e57d30ef544fe5dcd/packages/next/src/server/load-components.ts#L125-L218

// 抜粋
async function loadComponentsImpl() {
  console.log('Start requirePage _document and _app')
  // loadComponentsでは、_appと_documentのロードも行います
  ;[DocumentMod, AppMod] = await Promise.all([
    Promise.resolve().then(() => requirePage('/_document', distDir, false)),
    Promise.resolve().then(() => requirePage('/_app', distDir, false)),
  ])
  console.log('End requirePage _document and _app')


  console.log('Start other of loadComponents')
  // ...
  console.log('End other of loadComponents')


  console.log(`Start requirePage ${page}`)
  // 実際のページコンポーネントのロード
  const ComponentMod = await Promise.resolve().then(() =>
    requirePage(page, distDir, isAppPath)
  )
  console.log(`End requirePage ${page}`)

  // ...
}

次に、この関数の内部で呼び出されているrequirePageにも追加しました。調査のきっかけになったGitHubのコメントにも、ここに時間がかかっていると書かれていました。

https://github.com/vercel/next.js/blob/3c01e3a9a17e5cc8d060b87e57d30ef544fe5dcd/packages/next/src/server/require.ts#L108-L132

// 抜粋
function requirePage(){
  const pagePath = getPagePath(page, distDir, undefined, isAppPath)
  if (pagePath.endsWith('.html')) {
    console.log(`Start readFile ${pagePath}`)
    // Automatic Static Optimizationが適用されている場合は、
    // htmlとして出力されたファイルを読み込み、requireは実行されない。
    return promises.readFile(pagePath, 'utf8').catch((err) => {
      throw new MissingStaticPage(page, err.message)
    })
  }

  console.log(`Start require ${pagePath}`)
  const mod = require(pagePath)
  console.log(`End require ${pagePath}`)

  return mod
}

実際には他にもログを埋め込んでいますが、重要なのはこのあたりです。ホーム画面へのリクエストで、以下のようなログを想像していました。

Start requirePage _document and _app
  Start require `_document.js`
  End   require `_document.js`
  Start require `_app.js`
  End   require `_app.js`
End   requirePage _document and _app
Start other of loadComponents
End   other of loadComponents
Start requirePage /
  Start readFile `index.html`
End   requirePage /

このようなログで、タイムスタンプによってどの処理に時間がかかっているのかを把握できると考えていました。調査のきっかけになったGitHubのコメントでは、requirePageに時間がかかっているとあったので、Start require ...End require ...のタイムスタンプに開きがあることを期待していました。

実際にホーム画面をリクエストしたときのログは以下のような感じでした。同じログが複数あるため、どこから発生したログかわかりやすいように数字を書きました。また、時間がかかっているログに[WARNING]とつけています。

※ タイムスタンプは秒のみ表示

54: Start requirePage _document and _app (1)
54: Start requirePage _document and _app (2)
54:   Start require `_document.js` (1)
54:   End   require `_document.js` (1)
54:   Start require `_app.js` (1)
54:   End   require `_app.js` (1)
54:   Start require `_document.js` (2)
54:   End   require `_document.js` (2)
54:   Start require `_app.js` (2)
54:   End   require `_app.js` (2)
54: Start requirePage _document and _app (3)
54:   Start require `_document.js` (3)
54:   End   require `_document.js` (3)
54:   Start require `_app.js` (3)
54:   End   require `_app.js` (3)             [WARNING]
58: End   requirePage _document and _app (1)  [WARNING]
58: Start other of loadComponents (1)
58: End   requirePage _document and _app (2)
58: Start other of loadComponents (2)
58: End   requirePage _document and _app (3)
58: Start other of loadComponents (3)
...

Start require ...Start require ...の間にはそこまで時間がかかっていなく、よくわからない箇所に4秒も時間がかかっています。また、Start requirePage _document and _appと後続の処理が、なぜか3回実行されています。

まずは、なぜ_app_documentのセットが3回もrequireされているのかを調査しました。このログはloadComponentsが3度呼ばれていることを表しています。1つは実際にリクエストしているホーム画面のコンポーネントだと想定すると、あと2つの呼び出しがあります。loadComponentsを使っている場所を探したところ、以下のようなコードがありました。

https://github.com/vercel/next.js/blob/3c01e3a9a17e5cc8d060b87e57d30ef544fe5dcd/packages/next/src/server/next-server.ts#L217-L228

// 抜粋
class NextNodeServer extends BaseServer {
  constructor() {
    // ほとんどのリクエストで必要になるため、
    // 事前にウォームアップしておく
    loadComponents({
      distDir: this.distDir,
      page: '/_document',
      isAppPath: false,
    }).catch(() => {})

    loadComponents({
      distDir: this.distDir,
      page: '/_app',
      isAppPath: false,
    }).catch(() => {})
  }
}

NextNodeServerというのは、初回リクエスト時に作成されるクラスです。具体的には、render-server.tsinitialize関数で作成されるNextServerが保持しています。

上のコードでは、loadComponents_document_appで2回呼び出しているため、ログの内容と一致します。それでも、Start requirePage _document and _appが連続していることに疑問を感じるかもしれません。このログの直後にrequirePageが実行されるのだと考えてしまうと、ログの順序がわからなくなります。これを理解するためには、Promiseの知識が必要になってきます。まず、requirePageを呼び出しているコードは以下のようになります。

console.log('Start requirePage _document and _app')
;[DocumentMod, AppMod] = await Promise.all([
  Promise.resolve().then(() => requirePage('/_document', distDir, false)),
  Promise.resolve().then(() => requirePage('/_app', distDir, false)),
])

ここでは、Promise.allの中で直接requirePageを実行するのではなく、thenのコールバックで実行しています。Promiseはすぐにresolveしてthenのコールバックが実行されるように見えるのですが、実際にはそうはなりません。Promiseはすぐにreolveされるのですが、コールバックはマイクロタスクキューの最後尾に積まれます。マイクロタスクキューに積まれるため、reqiurePageStart require _document and _appの直後には実行されていません。

これでStart requirePage _document and _appが3度も表示されてる理由はわかりましたが、以下のログの間で4秒もの時間がかかっている理由はわかりませんでした。

54:   End   require `_app.js` (3)             [WARNING]
58: End   requirePage _document and _app (1)  [WARNING]

このログに関連するコードを簡単に展開すると以下のようになります。

console.log('Start requirePage _document and _app')
;[DocumentMod, AppMod] = await Promise.all([
  // ...
  Promise.resolve().then(() => {
    console.log('Start require `_app.js`')
    const mod = require('_app.js')
    console.log('End require `_app.js`')
    return mod;
  }),
])
console.log('End requirePage _document and _app')

上の2つのログは、実際には異なるloadComponentsの呼び出しなのですが、上のようなコードが実行されていると言えそうです。そして、awaitは対象のPromiseresolveされると、thenのように後続の処理をマイクロタスクに積みます。

コードだけを見ると、End require _app.jsEnd requirePage _document and _pageの間には何も処理がないように見えますが、awaitで実行フローが分割されているため、2つの処理は同期的に実行されているわけではありません。とすると考えられるのは、awaitの後続のEnd requirePage _document and _appを含む処理がマイクロタスクキューに積まれる前に、4秒間実行されるマイクロタスクが積まれているのではないかということです。

それを探すためには、実行されているすべてのコードを読む必要があるように思えました。これまでは必要そうな部分だけに着目することで、なんとかコードを読めていましたが、すべてのコードを読むとなるとNext.jsの内部構造の知識が足りません。

ここで、requirePageに時間がかかっているというコメントの通りになっていないように見えることが気になっていました。そのコメントでは、モジュールを読み込むのに時間がかかると書いてあります。僕はStart require ...End require ...の間でモジュールが読み込まれていると思い込んでいましたが、実際にどんなモジュールが読み込まれているかはわかりませんし、もしかしたら別の場所で読み込まれているのかもしれません。

モジュールの読み込みに時間がかかると仮定すると、謎の4秒の間にモジュールの読み込みが実行されている可能性はあると考えました。

モジュール解決のデバッグログ

Node.jsでモジュールの読み込みを表示する方法を調べていると、NODE_DEBUGという環境変数に、module,esmという値を設定すると良いことがわかりました。また、モジュールを読み込んで利用できるようにするプロセスのことをモジュール解決と呼ぶこともわかりました。

この環境変数を設定してみると、読み込まれているモジュールがすべて表示されるようになります。ログの量がとんでもないことになるのですが、やはり以下のログの間に大量のモジュール解決のログが表示されていました。

54:   End   require `_app.js` (3)             [WARNING]
58: End   requirePage _document and _app (1)  [WARNING]

ログをさらに遡ると、一番はじめのStart require (_document.js | _app.js)のあとにもモジュール解決のログは表示されていますが時間はかかっていません。それ以降のStart require (_document.js | _app.js)ではキャッシュを使用しているのか、モジュール解決は行われていません。

上のログの間のモジュール解決に時間がかかっていることはわかったのですが、なぜこの場所にログが表示されているのかわかりませんでした。_document.js_app.jsEnd requireが表示されているのでモジュールの解決が終わっているのだと思っていました。

これは、モジュール解決の同期/非同期が関係していました。理由がわからず悩んでいた時にビルドで生成される_app.jsのファイルを眺めていて気づいたのですが、_app.js_document.jsは、内部でrequire関数とimport関数を使用して動的にモジュールを解決しています。Node.jsはrequire関数は同期的、import関数は非同期的に実行します。import関数はawaitされています。つまり、Start require (_app.js | _document.js)を実行したときに、await importに到達した時点で制御が外に戻り、以降のモジュール解決がマイクロタスクキューに積まれます。モジュール解決がマイクロタスクになっているとすると、上のログの間でモジュール解決が実行されているのも納得できます。

これで、2つのログの間の不自然な4秒が、マイクロタスクとして実行されるモジュール解決であるとわかりました。そして、モジュール解決のログを見ていると、Mantineという文字列を含むモジュールの解決が大量にありました。

この結果から、Mantineへのimport文が大量のモジュール解決を引き起こしているのだとわかりました。_document.tsxを見てみると、MantineのColorSchemaScriptというコンポーネントをimportしており、ビルド時に生成される_document.jsには、動的なimport("@mantine/core")が含まれています。ひとつのコンポーネントを使用するためだけに、@mantine/coreのモジュール解決を発生させ、@mantine/coreが大量のMantineのモジュール解決を引き起こしていました。

不要な大量のモジュール解決が実行されてしまうのは、モジュール解決が動的に行われ、最適化がされないからだと思います。_document.tsx_app.tsx、SSRされるページのtsxファイルなど、バックエンドで実行される可能性のあるものは、ビルド時にimport文が動的importに変換されていそうです。なぜそうなっているかまでは調べきれなかったのですが、バンドルして必要なコードだけ含めるとモジュールのキャッシュが効かないというのもあるのかなあと思っています。

importを削除して改善する

これまでの調査から、_document.tsxの中でMantineからimportしているColorSchemaScriptがレスポンスを遅くしているということがわかりました。このコンポーネントは、darkやlightといったテーマの設定を埋め込むためのスクリプトタグをレンダリングするもので、ユーザーのデフォルトのテーマとの不一致によるちらつきを防ぐことができます。

このコンポーネントをimportするのをやめ、インラインで実装することでパフォーマンスを改善しました。ColorSchemaScriptの実装では、テーマ切替時にlocalStorageに保存された値を参照するようなコードもあるのですが、対象プロジェクトではテーマ切り替えは無いので、シンプルに実装することができました。

importを削除することで、 5 ~ 7秒 かかっていたレスポンスが 1 ~ 3秒 に短縮されました。

さいごに

Next.jsのWebアプリがコールドスタート時に遅くなる問題について調査し、改善を行いました。

調査の結果、1つのimportが原因で大量のモジュール解決が実行され、レスポンスが遅くなっていることがわかりました。当初、必要なモジュールだけが含まれていると考えていたのですが、そういった最適化は行われずに動的なモジュール解決に変換されているようでした。

また、調査を通じて、パフォーマンスの計測に役立つさまざまなツールやライブラリを知ることができました。特にNode.jsのプロファイリングツールについては初めて聞くものも多く、問題が発生したときに役に立つと感じました。一方でNode.jsに限らず、JavaScriptのモジュール解決についての知識が不足しているとも感じたので、もっと理解を深めていきたいです。

この投稿では、調査の過程を詳細に書きました。今後似たような問題が発生したときに、なにか一つでも役に立つことを願っています。

おまけ - SSRのパフォーマンス改善

投稿では、Automatic Static Optimizationが適用されている静的なページの問題を調査しましたが、SSRされるページに遷移すると同じ問題が発生します。ホーム画面へのリクエスト時にMantineのモジュールが解決されることはなくなったのですが、SSRされるページに遷移しようとすると、Mantineの大量のモジュール解決が実行され、時間がかかってしまいます。

ホーム画面からの遷移だけを考えるのであれば、prefetchするという解決策があります。もちろんSSRをやめることもできますが、僕は最初のレスポンスに完全なページが含まれるSSRは画面のちらつきが少なく、使っていて快適だと感じます。そこで、ホーム画面に最初にアクセスするということがわかっている場合には、ホーム画面で他のページをprefetchしてバックエンドでSSRを実行させ、Mantineのモジュールをキャッシュさせることができます。

これは、fetchを使ってprefetchしたいページをリクエストすることで実現できます。Next.jsにはrouterオブジェクトにprefetchという関数があるのですが、この関数はprefetch先のページに必要なJSやCSSファイルを読み込むだけで、SSRは実行しないので使えません。prefetchするページはMantineを使用していればなんでもよく、それ以外のページをSSRするときにもキャッシュが使用されるので、効果があります。

ホーム画面が表示されると、データ取得のためにNext.jsで実装したAPIが実行されます。このAPIの実行にも大量のモジュール解決が必要になるため、コールドスタート時には数秒待たされます。そのローディング時にprefetchも並行で行うと、データが画面に表示されてすぐにrefetchが終わることもあるため、そのデータの詳細画面への遷移をすぐに行えることも多いです。

なぜかキャッシュが使われないケースもあるのですが、そこまで調査はできていません。

Discussion