技術 約8分で読めます

Streaming SSRは最も遅いAPI待ちの空白を減らす

いけさん目次

DEV Communityに転載されていたParetoの記事「Your Page Is Only as Fast as Your Slowest API: The Case for Streaming SSR」がよかった。
Chrome DevToolsのNetworkでHTMLドキュメントだけが1,400ms「Waiting for response」のまま止まり、APMを見ると裏では5つのAPIを待っていた、という導入だ。

例として挙げられている内訳はこう。

データ待ち時間
認証チェック30ms
カート数50ms
ヘッダーナビゲーション80ms
商品詳細200ms
パーソナライズ推薦1,400ms

従来のSSRで全部を Promise.all してからHTMLを返すと、TTFBは一番遅い推薦APIに引っ張られる。
4つのデータは200ms以内に揃っているのに、ブラウザは1,400msのあいだHTMLを1バイトも受け取れない。

問題は平均ではなく最大値になる

ページが複数の依存先を持つほど、「どれか1つが遅い」確率は上がる。
原典では、各APIが1%の確率で通常の5倍遅くなると仮定している。

5個のAPIなら、少なくとも1つが遅い確率は約4.9%。
10個なら約9.6%。
ページ全体の体感はAPIレイテンシの平均ではなく、同時に待っている集合の最大値で決まる。

これはECの商品ページ、ログイン後ダッシュボード、管理画面、SaaSの設定ページでよく起きる。
ユーザー情報、権限、ナビゲーション、通知数、A/Bテスト、推薦、在庫、価格、外部SaaSのステータスを1つのloaderに詰めていくと、ページは少しずつ「一番遅い依存先待ち」になる。

Cloudflare Workersでリアルタイム分析基盤を構築した話では、イベント取り込みのP95やダッシュボード更新レイテンシを見ていた。
あれはリクエスト処理パイプラインの話だったが、今回のSSRはブラウザが最初のHTMLを受け取るまでの話だ。
同じ低レイテンシでも、見る場所が違う。

Streaming SSRで待つ境界を分ける

Streaming SSRは、HTML生成を「全部揃ってから1回で返す」から「先に返せるシェルと、あとから流す部分」に分ける。
React公式の renderToPipeableStream ドキュメントでも、データがすべてロードされる前にユーザーへコンテンツを見せられると説明されている。

Paretoの記事では、速いデータはloader内で await し、遅い推薦APIは defer() でPromiseのまま返す例になっている。

export async function loader(ctx) {
  const [user, nav, product] = await Promise.all([
    getUser(ctx),
    getNav(ctx),
    getProduct(ctx),
  ]);

  return defer({
    user,
    nav,
    product,
    recs: getRecs(ctx),
  });
}

ページ側では遅い領域だけを SuspenseAwait で囲む。

export default function ProductPage({ data }) {
  return (
    <>
      <Header user={data.user} nav={data.nav} />
      <ProductDetail product={data.product} />

      <Suspense fallback={<RecsSkeleton />}>
        <Await resolve={data.recs}>
          {(recs) => <Recommendations items={recs} />}
        </Await>
      </Suspense>
    </>
  );
}

この場合、HTMLシェルは商品詳細の200msが終わった時点で流せる。
推薦APIは1,400msかかるままだが、ユーザーはヘッダーと商品詳細を先に読める。

原典の数字では、従来SSRのTTFBは約1,440ms。
Streaming SSRでは、RTT込みで約240msまで下がる。
バックエンドの仕事量は変わらない。
変わるのは「遅いAPIがページ全体を人質に取るかどうか」だ。

クライアントfetchとキャッシュだけではずれる場面

遅い部分だけクライアントでfetchすればよさそうにも見える。
ただし、HTML、CSS、JS、hydrate、fetch、renderというウォーターフォールに変わるだけなので、遅い領域の表示完了はむしろ後ろへずれることがある。

SEOが絡む内容なら、初期HTMLに入らない問題も出る。
商品レビュー、地域別価格、比較表のように検索評価へ載せたい情報をクライアント後読みだけにすると、ページの主内容が薄く見える。

キャッシュも万能ではない。
デプロイ直後、キーのミス、パーソナライズ、在庫や価格のようなリアルタイム性があるデータでは、最初からキャッシュヒットを前提にできない。

Streaming SSRはキャッシュの代替ではない。
キャッシュできるものはキャッシュし、キャッシュしにくいものはページ全体を止めない位置へ逃がす、という組み合わせになる。

DevToolsではHTML行とWaterfallを見る

この手の問題は、まずブラウザ側でHTMLドキュメントのリクエストを見る。
JSや画像ではなく、一番上のDocument行だ。

確認するのは主にこのあたり。

観点見るもの
HTMLのWaitingサーバーが最初のバイトを返すまで止まっている時間
TTFBネットワークRTT込みで初回応答が返るまで
FCPHTML受信後に最初の描画が起きるまで
Suspense fallback遅い領域の骨組みが先に出ているか
CLSfallbackと実データの高さ差でレイアウトが跳ねていないか

ブラウザ側でDocumentのWaitingが長く、APM側で複数fetchのうち1つだけが突出しているなら、Streaming SSRの候補になる。
逆に、Documentはすぐ返っているがJSのダウンロードやhydrateが詰まっているなら、Streaming SSRよりバンドル分割やクライアント実行時間の話だ。

HLS動画抽出の非同期パイプラインとPinterest社内MCP基盤では、FFmpegの出力をFastAPIの StreamingResponse で流してTTFBを短くする話を書いた。
今回も「処理完了前に返せるものから返す」という考え方は同じだが、SSRでは途中チャンクの失敗だけでなく、Suspense fallbackの高さ、エラー境界、SEO対象データをどこまで初期HTMLに含めるかが問題になる。

何でもstreamすればいいわけではない

Streaming SSRが効くのは、ページ内に「先に見せられる部分」と「遅れても意味が壊れない部分」が分かれている場合だ。
商品詳細と推薦、プロフィール本体と投稿一覧、ダッシュボードの概要と重いグラフ、管理画面の設定フォームと監査ログのような分け方なら使いやすい。

逆に、全データが揃わないとレイアウトも意味も成立しない画面では、細かくstreamしてもスピナーが増えるだけになる。
50ms未満で全部返る小さなページも、ストリーミングの設計コストに見合わない。

Next.jsのApp RouterならRSCとSuspense、RemixやReact Routerなら defer()Await、Paretoなら同じくloader-plus-defer() のモデルで実装できる。
CloudflareがAIで1週間でNext.jsをVite上に再実装、vinextが本番稼働でも触れたように、最近のReact系フレームワークはSSR、RSC、ストリーミングをフレームワーク機能として抱え込んでいる。
選択肢の違いはAPI表面や運用対象であって、遅い依存先でHTML全体を止めないという目的は共通している。

見る順番としては、まずAPMでloader内fetchのp95を見る。
200msを大きく超える依存先があり、それが初期表示に必須でないなら、await から外せる候補になる。
そのうえでDevToolsのDocument行を見て、TTFBが「残した最遅await + RTT」付近まで下がるか確認する。

Streaming SSRは遅いAPIを速くしない。
ただ、遅いAPIがあることを理由に、ページ全体を真っ白にしておく必要はなくなる。

ワーカーに逃しても待ちは消えない

遅いAPI呼び出しをNode.jsのWorker Threadに移す案もある。
ただ、ここでボトルネックになっているのはCPUではなくI/O待ちだ。
fetch(slowRecsAPI) をWorker Threadで実行しても、APIの応答が1,400msなのは変わらない。
待つ場所がメインスレッドからワーカーに移るだけで、HTMLを返せるタイミングは同じになる。

Worker Threadが効くのは、JSONの巨大パースや画像リサイズのようにCPUがメインスレッドを占有する場面だ。
外部APIの応答待ちはイベントループをブロックしないので、ワーカーに逃がしてもTTFBは縮まない。

ワーカーに投げてSSRレスポンスを先に返す方式なら待ちは消える。
ただしそれはクライアントfetchと構造が同じで、データが初期HTMLに入らない。
SEO対象データなら前述のクライアントfetch節で書いた問題がそのまま当てはまる。

ゾンビの懸念もある。
Node.jsのWorker Threadは親プロセスがクラッシュしても自動で終了する保証がない。
リクエストがキャンセルされてもワーカーは走り続けるし、unhandled rejectionでハングすると外から気づきにくい。
リクエストごとにワーカーを生成するならプールで管理できるが、プールの監視・再起動・タイムアウト処理が運用に乗ってくる。

Streaming SSRの Suspensedefer() はフレームワークがチャンクの送信とエラー境界を管理する。
ワーカーのライフサイクルを自前で面倒見る必要がなく、壊れ方も予測しやすい。

ブラウザ側のWeb Workerで遅いAPIを裏で取る案も構造は同じだ。
fetch() はメインスレッドでも非同期I/Oなので、Web Workerに移しても待ち時間は変わらない。
JSON.parseが巨大で数十msブロックするなら分ける意味はあるが、通常のAPI応答サイズではメインスレッドへの負荷はほぼない。

ゾンビの問題はDedicated Workerならページのunloadで消える。
ただSPAだとルート遷移でunloadが発生しないので、worker.terminate() を忘れると前のルートで起動したWorkerが残る。
postMessageのコールバックがunmount済みコンポーネントのstateを触ってWarningが出るのもよくあるパターンだ。
AbortControllerでfetchをキャンセルできるなら、わざわざWorkerを挟む理由が薄い。


ゾンビプロセスそのものの話は昨日の記事で掘り下げた。
自作のかなチャットでもワーカーのゾンビがわりと出て、逆に処理が遅くなったりする。
なかなか難しい。

参考: