Next.jsのPPR + StreamingがVercelで実行されるときの全体像を掴む
こんにちは。sumirenです。
イントロダクション
先日、Next.js 15 RCが出ました。App RouterでPartial Prerenderingが広く使われ始める日も近いのではないでしょうか。
Partial Prerenderingは、特にStreamingと一緒に利用し、Vercelにデプロイした場合、強力に最適化が行われます。最適化は非常に高度で、部分的な静的レンダリングといったアプリレイヤから、エッジとオリジンの使い分けといったインフラの構成まで絡む複雑な設計です。最終的にVercelにデプロイされた後の全体の動作イメージが頭に入っている方は少ないのではないでしょうか。
この記事ではPartial PrerenderingとStreamingについて、Vercelデプロイ後の動作イメージがついている状態を目指します。静的ルートやサーバーサイドフェッチなど単純な例から順番に議論していくことで、PPRした場合との対比を可能とし、容易に理解できるよう工夫します。また、図解も設け、図解同士を比較してもらえるようにします。
免責事項
筆者はNext.jsやVercelの関係者ではなく、この記事はドキュメントと検証した振る舞いをベースに書いています。記事の内容には誤りが含まれる可能性があります。記事の誤りや誤解を生む表現に対する指摘は歓迎しますが、対応有無は(返事をするか含めて)筆者の気分や余力も踏まえて恣意的に決めます。
1. ルートが静的な場合
まず、最も簡単な例として、ルートが静的な場合の流れを考えてみましょう(Static Rendering(デフォルト))。静的なトップページにリクエストが来たとします。
静的ルートの場合、ページ全体がビルド時にレンダリングされ、その結果がエッジにキャッシュされます。リクエストが来るたびに、エッジからそのキャッシュされたページが返されるため、非常に高速です。この流れでは、オリジンサーバーに到達する必要がないため、FCPは最速になります。
当然ながら、サーバーサイドフェッチ等の動的処理が不要であれば、この流れが一番理想的です。
※実際にはVercelのCDN層はオリジンからのレスポンスのHTTPキャッシュ(s-maxage等)の指示を見てキャッシュしているかもしれません。本質的な性能面の違いはないため上記図としています。
2. ルートが動的でnode実行の場合
ここからはルートが動的な場合です。
まず、nodeランタイムでの実行です(Dynamic Rendering かつ node実行(デフォルト))。この場合、実行時にデータフェッチやレンダリングがオリジンのnode環境で行われ、オリジンからクライアントにリクエストが返されます(便宜上、この記事では非エッジ的な環境をオリジンと呼ぶものとします)。
nodeランタイムは、オリジンにあるうえに起動も実行も遅い環境なので、これは多くの場合パフォーマンスが低くなる構成と筆者は考えています。
一方で、結局のところDBやバックエンドAPIがオリジンにあることから、最近ではサーバーサイドフェッチを伴う場合はオリジンで処理をしたほうが効率が良いという風潮もあります。例えばCloudflareもこうしたトレードオフが存在することを認めていて、Cloudflare Workerに関してDBなどのオリジンに近い位置に配置することを選択できる機能を開発しています。この伏線は後ほどPartial Prerenderingで回収します。
※とはいえ、筆者個人としては、国内に閉じていてかつVercel等のフロントサーバーからバックエンドへのリクエスト数がそこまで多くなければ、普通にエッジで実行したほうが速いように感じてはいます。
なお、図上のシーケンスは便宜上のものです。実際にはレンダリングプロセスのなかでフェッチ処理を待機しているものと思われますが、ソフトウェア全体のアーキテクチャを理解するという趣旨に照らして、以降も簡潔に記載していきます。
3. ルートが動的でedge実行の場合
次に、ルートが動的でedgeで実行される場合についてです。export const runtime = "edge"
をRoute Segment Configに指定するとこうなります。
この場合、node実行時とほとんどシーケンスは同じですが、全てエッジで処理が行われます。
前のセクションで補足したとおり、想定するユーザーとオリジンの間の地理的距離がグローバル規模でなければ、Functionの起動速度や実行速度が速いことも相まって、node環境よりedge環境のほうが速いと筆者は感じています。
しかし、実際のところ、今後はedge環境かnode環境かで悩むことは少なくなると思われます。最後に解説するPartial Prerendering + Streamingが大抵の場合最適解になり、そのパターンではランタイムを選ぶ余地がないからです。
4. Streaming実行の場合
App Routerでは、ルートが動的な場合、Server ComponentとSuspenseを組み合わせることで、Streamingを利用できます。node環境かedge環境を選べますが、どちらでもシーケンスはほとんど同じと思われるため、ここでは筆者が好んでいるedge環境のほうで解説します。
Streamingをフル活用するには、Suspenseのなかにフェッチなどの非同期処理を全て閉じ込めます。この前提では理想的に処理が進み、図解のような流れになります。レンダリングプロセスにおいてSuspense内でしかPromiseがthrowされないことで、ページのレンダリングは即座に完了し、非同期処理を待たずに初期状態のHTMLが返送されます。このとき、Suspenseの部分はfallbackが描画されています。
その後、そのままエッジ(Edge Function)上でフェッチ処理を継続し、Promiseが完了したSuspenseから順番にクライアントに差分データが送られます。クライアント側ではJSが動いてfallback部分を置き換えます。Streamingの実装はNext.jsのビルド方法に依存しており、ローカルでnext build && next start
した場合はHTTP/1.1でTransfer-Encoding: chunked
を使って返ってきますが、Vercel上ではHTTP/2で返ってきます。いずれにおいても、1本のHTTPリクエストに対してレスポンスをストリーム的に返している点は同様です。
(図解上は縦に長くなってしまっていますが)このアーキテクチャでは、前に比べて、フェッチを待たずにHTMLが返送できているため、FCPが速くなります。かつ、ブラウザから再度リクエストを送るわけではなく、裏ではフェッチ処理も並行で行っているため、LCPへの悪影響もありません。これがStreamingの強みです。
5. 【本題】PPR + Streaming実行の場合
メインテーマのPPR + Streamingです。複雑なこのパターンも、ここまでのアーキテクチャの違いを理解していれば、すんなりと理解できるのではと思います。
上記のStreaming単体でも十分速いのですが、Partial Prerendering + Streamingではさらに2点を改善しています。
- 静的部分(Suspenseはfallbackが描画された状態)はビルド時に事前生成しておき、リクエスト時のレンダリング時間をなくす
- フェッチはバックエンドやDBに近いオリジンでやるべき論を反映し、Streamingの後続処理はオリジンにあるnode環境(Serverless Functions上)で実行
これにより、FCPのオーバーヘッドだったエッジ上でのReactレンダリングのコストさえ踏み倒し、かつフェッチ処理はオリジンにまとめるというランタイムの使い分けを達成しています。edge + Streamingも十分素晴らしかったですが、PPR + Streamingでは僅かなオーバーヘッド/デメリットも潰し、現状考えられる最適の構成になっているように思います。
※実際には差分データはオリジンから直接返しているというより、ジョブとしてオリジンでServerless Functionが立ち上がってフェッチを行い、そのジョブの完了をクライアントからのHTTP接続をつなぎっぱなしにしたEdge Functionが監視して返送するような動きに見えますが、簡単のため上記図としています。
ちなみに、現状のNext.jsではPPRの配信やStreamingの後続処理のランタイムを選択することはできません。筆者個人としてはStreamingの後続処理もedgeで行う余地を残してほしかったので残念ですが、検討事項が減るという意味では良かったのかもしれません。
まとめ
この記事では、Next.jsのApp Routerを利用したサーバーサイドフェッチにおける理想的な処理の流れについて解説しました。ここまでの議論を踏まえると、特に以下の条件を満たすほど、FCPを高めるうえでは理想に近いといえます。
- 初期状態のHTMLの配信はエッジで完結しており、一切の非同期処理を待たず、Function内に閉じるCPU処理であっても限りなくゼロに近づけること
Partial RenderingとStreamingを組み合わせることで、上記理想がほとんど完全に達成されていると言えるのではないでしょうか。実際に筆者の環境で計測してみても、PPR + Streamingで初期状態のHTMLが配信されるまでのレイテンシは、理論上CDNにキャッシュされている静的ファイルが配信されている場合とほとんど変わりませんし、それが不思議ではないアーキテクチャに仕上がっています。
この記事を通じて、Partial PrerenderingとStreamingについて、Vercelデプロイ後の動作イメージがつけば幸いです。
よければTwitterのフォローもどうぞ!
Discussion