フレームを先に送れ:HTML Streaming が問い直した「送る単位」の設計判断

著者: ClaudeMix Team |
フレームを先に送れ:HTML Streaming が問い直した「送る単位」の設計判断のサムネイル

はじめに

SSRは「速い」と言われる。

サーバーでHTMLを作って返すから、クライアントでJSが動くより先にコンテンツが見える。そう理解していた。

ある日、Lighthouseのタイムライン計測を見た。

ページの「白い時間」が長かった。サーバーは動いていた。でもHTMLは来ない。来ない。来ない。そして一気に来た。

SSRが速いのではない。SSRは「全部終わったら速い」だった。

React RouterのHTMLストリーミングを調べたとき、「送る単位を変える」という発想があることを知った。

この記事をお勧めしない人

  • 現状のTTFB(Time to First Byte)に問題を感じていない人
  • データ取得のすべてが50ms以内に完了している人
  • ブラウザへの「部分的なHTML」に違和感を覚える人

もし一つでも当てはまらないなら、読み進める価値があるかもしれません。

【思想衝突】

  • Complete HTML vs Progressive Delivery
  • Sequential Safety vs Streaming Complexity

① 問題提起:従来SSRが払っている「最遅クエリ税」

従来のSSRはこう動く。

Request
  → loader: getUser()        → 20ms
  → loader: getPost()        → 180ms  ←── 最遅
  → loader: getRecommended() → 90ms
  ↓ 全部終わったら
  → renderToString()         → 5ms
  → HTML送信

TTFBは180msではない。20 + 180 + 90 + 5 = 295ms だ。

全員を待つ。最も遅いクエリが全体のコストを決める。これを「最遅クエリ税」と呼ぶことにする。

  • 【ユーザーには白い画面】:サーバーが計算している間、ブラウザは何も受け取れない。スケルトンすら出ない。
  • 【並列化の恩恵が半分】:クエリを並列にしても、Promise.all は最遅に引きずられる。
  • 【TTFB = データ取得の合計時間】:レンダリングコストより、データ待ちが支配的になる。

これは設計ミスではない。renderToString というAPIの設計だ。文字列は「完成してから」返せる。途中では返せない。

② なぜ皆そうしているのか

renderToString は長年、SSRの標準だった。

理由は単純で、HTTPレスポンスは「文字列か、バイト列か、ストリームか」という選択がある。かつてのサーバーフレームワークはストリームより文字列の方が扱いやすかった。Node.jsも初期はストリームAPIが複雑だった。

フレームワークは「全部渡せば全部やってくれる」という契約で設計された。

解決しようとした問題 生まれた副産物
HTMLを完全な状態でブラウザに届ける データが揃うまで何も送れない
一貫したHTMLを保証する 最遅クエリが全体のTTFBを決定する
シンプルなAPIでSSRを実現する 部分的な配信という選択肢が消えた

誰も怠慢ではなかった。「完全なHTMLを送る」という判断が、「待つ」を内包していただけだ。

③ HTML Streaming は何を優先したのか

renderToPipeableStream と React Router の streaming は、「送る単位」を変えた。

考え方はこうだ。ページのほとんどは静的だ。ヘッダー、レイアウト、ナビゲーション、記事の骨格。これらはデータを待たなくても生成できる。

待つ必要があるのは、【データに依存するごく一部の領域】だけだ。

だったら、送れるものから送ればいい。

Request
  → シェル(ヘッダー・レイアウト)を生成 → 即座に送信  ← FCP ここ
  → loader: getUser()        → 20ms → 解決次第ストリーム
  → loader: getPost()        → 180ms → 解決次第ストリーム
  → loader: getRecommended() → 90ms → 解決次第ストリーム

ブラウザはHTMLのシェルを受け取ったら描画を始める。スケルトンが出る。ローディングスピナーが出る。データが届いたら差し込まれる。

「フレームを先に送る」。中身は後から届く。

優先した価値

  • 【体感速度(FCP)】:ユーザーが「何かが始まった」と感じる時間を最小化する。白い画面の時間をゼロに近づける。
  • 【プログレッシブ・エンハンスメント】:SSRのスケルトンを起点に、データが届くたびに画面が完成する。ブランクスクリーンではなく、段階的な完成。

意図的に後退させた価値

  • 【実装の単純さ】:defer() / Await / Suspense の新しいパターンを理解する必要がある。loaderが「何を待つか」の設計が必要になる。
  • 【デバッグの容易さ】:部分的に届くHTMLは、問題の切り分けが難しい。ストリームの途中でエラーが起きたとき、どこで何が失敗したかが見えにくい。

④ 実装でどう解決しているか

`defer()` と `Await` コンポーネントパターン

React Routerのストリーミングは3つのAPIで成立する。

【1. loaderで defer() を使う】

通常のloaderは全データを await してから返す。defer() を使うと、Promiseを解決前のまま返せる。

// app/routes/blog.$slug.tsx
import { defer } from "react-router";

export async function loader({ params }: LoaderFunctionArgs) {
  // 速いデータは await する(シェルに必要)
  const postMeta = await getPostMeta(params.slug);

  // 遅いデータは await しない(後からストリーム)
  const relatedPosts = getRelatedPosts(params.slug); // intentionally not awaited

  return defer({
    postMeta,      // 即座に利用可能
    relatedPosts,  // Promiseのまま渡す
  });
}

【2. コンポーネントで Suspense + Await を使う】

import { Await } from "react-router";
import { Suspense } from "react";

export default function BlogPost() {
  const { postMeta, relatedPosts } = useLoaderData<typeof loader>();

  return (
    <article>
      {/* postMeta はすでに解決済み — 即座にレンダリング */}
      <h1>{postMeta.title}</h1>
      <p>{postMeta.publishedAt}</p>

      {/* relatedPosts は非同期 — Suspense でフォールバックを表示 */}
      <Suspense fallback={<RelatedPostsSkeleton />}>
        <Await resolve={relatedPosts}>
          {(resolved) => <RelatedPostsList posts={resolved} />}
        </Await>
      </Suspense>
    </article>
  );
}

3. サーバーでストリームを有効にする

React Routerのサーバーエントリーで renderToPipeableStream が使われていれば、defer() は自動的にHTTPチャンクとして流れる。

// app/entry.server.tsx(概念)
import { renderToPipeableStream } from "react-dom/server";

export default function handleRequest(request, responseStatusCode, responseHeaders, routerContext) {
  const { pipe } = renderToPipeableStream(
    <ServerRouter context={routerContext} url={request.url} />,
    {
      onShellReady() {
        // シェルが準備できたら即座に送信開始
        response.setHeader("Content-Type", "text/html");
        pipe(response);
      },
    }
  );
}

シェルが onShellReady で確定した瞬間にHTTP接続に流し込む。Promiseが解決するたびに、<script> タグとして差分HTMLがストリームに追記される。ブラウザはそれを受け取り、対応する Suspense フォールバックを実コンテンツに置き換える。

resolveFrame という考え方

React Routerのストリーミングを一言で表すとすれば「フレームの解決」だ。

写真フレームと写真の関係を考えてほしい。フレーム(額縁)は写真が届く前から壁に掛けられる。写真が届いたら嵌め込む。

React Routerも同じだ。レイアウトシェル(フレーム)を先に送る。データ(写真)が解決するたびに嵌め込む。

ブラウザに届くのは最初「空のフレーム」だ。ユーザーはそれを見て「ページが来た」と感じる。FCPはそこで発生する。その後、写真(データ)が届くたびに画面が完成していく。

⑤ 何を捨てたか

  • 【実装の単純さ】:loaderを「何をawaitするか」「何をdeferするか」に分類する設計が必要になる。SuspenseAwait の組み合わせはReactの中でも比較的新しいパターンで、チームに浸透させるコストがある。
  • 【エラーハンドリングの明確さ】:ストリームの途中でPromiseがrejectした場合、エラーバウンダリの動作が従来のSSRより複雑になる。シェルはすでにブラウザに届いているため、HTTPステータスを後から変えられない。
  • 【デバッグの容易さ】:部分的に届くHTMLをDevToolsで確認するのは、一度に届くHTMLよりも難しい。チャンクの境界がどこかを把握する必要がある。
  • 【キャッシュの単純さ】:ストリーミングレスポンスはCDNにキャッシュしにくい。Content-Typeが text/html でも、チャンクが分割されることで一部のキャッシュ層が正しく扱えないことがある。

これらを許容できるかどうかが採用判断の核心になる。

そして正直に言うと、【遅いデータ取得が存在しない状態で導入するメリットはほとんどない】。

⑥ ClaudeMix への適用

ClaudeMixは現時点でHTML Streamingを実装しないことを選んだ。

理由は、現在のデータソースに遅いものが存在しないからだ。

現在のデータソースとその特性

データソース 取得速度 ストリーミングの恩恵
Markdownファイル 高速(ファイルシステム) なし
YAMLスペックファイル 高速(インメモリ) なし
Cloudflare D1 通常は高速(50ms以下) ほぼなし
外部APIなし

ブログ記事詳細ページを例にすると、「データ」とはMarkdownの本文そのものだ。外部APIを待つ必要がない。MarkdownをHTMLに変換する処理も、Cloudflare Workersの制約内では十分に速い。

ランディングページは静的コンテンツ中心で、ストリーミングの意義がない。

Decision Duelは質問データをYAMLから読む。YAMLはビルド時に解析済みであり、ランタイムの取得コストはゼロに近い。

ただし、以下のいずれかが実現したら実装する

// future: 設計ベクトルのリアルタイム計算にストリーミングを適用する
export async function loader({ params }: LoaderFunctionArgs) {
  // 速い:セッションメタデータは即座に取得
  const fastData = await getPostMeta(params.slug);

  // 遅い:設計ベクトルの計算は重い処理(意図的にawaitしない)
  const slowVector = computeDesignVector(params.sessionId);

  return defer({ fastData, slowVector });
}

実際にストリーミングが有効になるシナリオ:

  • 【コラボレーティブな設計ベクトル計算】:複数ユーザーの判断データを集計してリアルタイムで比較する機能が生まれたとき
  • 【AI生成の思想IRテキスト】:Anthropic APIへのリクエストが必要になり、レスポンスに数秒かかるとき
  • 【リアルタイムリーダーボード】:他のユーザーの設計軸スコアをD1から集計するとき(複数クエリ)

「将来に備えて今すぐ入れる」よりも、「アーキテクチャが対応できることを把握した上で、必要が生じたら入れる」という判断にした。React Routerはネイティブでストリーミングをサポートしている。必要になったとき、loaderに defer() を追加するだけで対応できる。

⑦ この設計は誰向けか

向いている

  • 外部APIへのリクエストがある:天気APIや決済システム、AIサービスなど、100ms以上かかるデータ取得が存在するプロジェクト
  • ページに重い集計クエリがある:大量データのD1集計、ユーザー固有のレコメンデーション計算など
  • FCP(First Contentful Paint)の数値改善が重要:コンテンツ型サービスで、ユーザーが「何かが始まった」と感じるまでの時間を最短化したい
  • プログレッシブ・エンハンスメントを設計思想として持つ:スケルトンUIを起点に段階的に完成させる体験を価値と見なすチーム

向いていない

  • 全データが高速に取得できる:50ms以下で揃うなら、完全なHTMLを送った方が実装がシンプルで正しい
  • 小規模チーム・個人開発:defer() / Await / Suspense の学習コストを払うより、明解なSSRの方が認知負荷が低い
  • CDNキャッシュを最大限使いたい:ストリーミングレスポンスはキャッシュ戦略が複雑になる
  • エラーハンドリングを完全に制御したい:ストリームの途中でエラーが起きたときの挙動を完全に把握するのは難しい

⑧ 思想の衝突

Webの標準的な期待は「完全なHTMLが届く」だ。

HTTPステータス200でContent-Typeがtext/html。そのレスポンスには、ページを表示するために必要なすべての情報が含まれている。少なくとも、そういう前提でWebは設計されてきた。

HTML Streamingはその前提を崩す。

レスポンスは途中から始まり、途中でSuspenseフォールバックが入り、後から<script>タグで差し込まれる。「完全なHTML」ではなく「段階的に完成するHTML」だ。

【「完全なHTMLを送るべき」】 という価値観からは、ストリーミングは実装を複雑にする割に保証が減る設計に見える。

【「体感速度が最優先」】 という価値観からは、ストリーミングはユーザーが感じる待ち時間を最短化する正しい設計に見える。

どちらが正しいかではない。どちらの価値観を、今のプロダクトで先に置くかだ。

【「実装しない」も設計判断だ】。ClaudeMixが現時点でストリーミングを採用しないのは「知らなかったから」ではない。データソースが速く、複雑さを払う理由がないからだ。この区別は重要だ。

設計とは、何を便利にするかではない。

どの価値観を、どの順番で優先するかだ。

⑨ あなたはどちらを選ぶか

全データが揃うまでHTMLを待つか。送れるものから送るか。

実装の明快さを守るか。FCP改善のために複雑さを受け入れるか。

遅いクエリが現れる前から備えるか。問題が見えてから対処するか。

この記事はClaudeMixで動いている。設計判断を二択で問い続けるプロダクトだ。あなたが今読んだ問いも、そこから来ている。

Decision Log