SSEストリーミングにJSON フォールバックを組み合わせる:プロダクションで安定動作させる実装パターン

SSEストリーミングにJSON フォールバックを組み合わせる:プロダクションで安定動作させる実装パターン

SSEストリーミングはプロダクション環境ではプロキシバッファリングや接続断などで壊れやすい。本記事では、SSEを主経路としつつ障害時にJSON APIへ自動フォールバックするパターンを、Next.js BFFプロキシや並列ストリーム協調完了と合わせて紹介します。
2026.07.29

はじめに

LLMチャットUIやリアルタイムデータ表示では、Server-Sent Events(SSE)によるストリーミングが定番です。しかしプロダクション環境では、SSEは思った以上に壊れやすいことがわかりました。

  • リバースプロキシがイベントをバッファリングして、レスポンスが一括で届く
  • 社内ネットワークのファイアウォールが長時間接続を切断する
  • 中間のロードバランサーがチャンク転送を想定していない

この記事では、SSEストリーミングを主経路としつつ、SSE障害時にJSON APIに自動フォールバックするパターンを紹介します。

前提・環境

  • フロントエンド: Next.js 15 (App Router, React 19), TypeScript
  • バックエンド: FastAPI (Python 3.12)
  • BFF: Next.js App Router のRoute Handlers(SSEプロキシ)

アーキテクチャ概要

sse-streaming-json-fallback-production-pattern-architecture

フロントエンドは同じバックエンドに対して、SSEストリーミングエンドポイントとJSONエンドポイントの両方を用意します。SSE接続が失敗した場合、ユーザー操作なしでJSON APIに自動フォールバックします。

バックエンド: SSEイベントの構造化

SSEのイベントを生の文字列ではなく、型付きの構造化イベントとして設計します。

stream_utils.py
import json
from typing import Any

def sse_event(name: str, payload: Any) -> str:
    """SSEイベントをフォーマットする。"""
    data = json.dumps(payload) if not isinstance(payload, str) else payload
    return f"event: {name}\ndata: {data}\n\n"

def sse_error(message: str, code: str | None = None) -> str:
    payload = {"message": message}
    if code:
        payload["code"] = code
    return sse_event("error", payload)

def sse_meta(**kwargs: Any) -> str:
    return sse_event("meta", {k: v for k, v in kwargs.items() if v is not None})

イベント名で処理を分岐できるため、フロントエンドのaddEventListenerと自然に対応します。

チャットストリーミングのイベントシーケンス

sse-streaming-json-fallback-production-pattern-sequence

用途に応じてイベント種別を使い分けます。

イベント名 用途 ペイロード
meta メタデータ(trace_id, model等) オブジェクト
token ストリーミングテキスト {"content": "..."}
sources RAGソース情報 配列
ui UI固有のペイロード オブジェクト
error エラー {"message": "...", "code": "..."}
done 完了シグナル {}

SSEレスポンスヘッダー

headers = {
    "content-type": "text/event-stream",
    "cache-control": "no-cache",
    "connection": "keep-alive",
    "transfer-encoding": "chunked",
    "X-Accel-Buffering": "no",
}
return StreamingResponse(event_gen(), headers=headers, media_type="text/event-stream")

X-Accel-Buffering: no が最も重要です。 nginxやリバースプロキシは、デフォルトでレスポンスをバッファリングして一括で返そうとします。このヘッダーがないと、SSEイベントが全部たまってから一度に届き、「ストリーミングしているのに全然流れてこない」という問題が起きます。

BFF: SSEプロキシ

Next.jsのRoute Handlerでバックエンドのストリーミングレスポンスをそのまま中継します。

app/api/chat/stream/route.ts
import { NextRequest, NextResponse } from "next/server";

export async function POST(req: NextRequest) {
  const body = await req.json();
  const traceId = req.headers.get("x-trace-id") ?? crypto.randomUUID();

  const res = await fetch("http://127.0.0.1:8765/v1/chat/stream", {
    method: "POST",
    headers: {
      "content-type": "application/json",
      "x-trace-id": traceId,
    },
    body: JSON.stringify(body),
  });

  if (!res.ok || !res.body) {
    return NextResponse.json(
      { error: "Backend error" },
      { status: res.status },
    );
  }

  // ストリーミングボディをそのまま転送(読み取らない)
  return new Response(res.body, {
    headers: {
      "content-type": "text/event-stream",
      "cache-control": "no-cache",
      "X-Accel-Buffering": "no",
      "x-trace-id": traceId,
    },
  });
}

重要: res.bodyを読み取らずにそのままResponseに渡す。 ボディをtext()json()で読み取ると、ストリーミングが途切れて一括レスポンスになります。

フロントエンド: SSE接続とJSONフォールバック

基本パターン: 単一ストリーム

function useConvert(url: string) {
  const esRef = useRef<EventSource | null>(null);
  const [markdown, setMarkdown] = useState("");
  const [busy, setBusy] = useState(false);

  const start = useCallback(() => {
    setBusy(true);
    setMarkdown("");

    // SSEストリーミング接続
    const es = new EventSource(`/api/confluence/convert/stream?url=${encodeURIComponent(url)}`);
    esRef.current = es;

    es.addEventListener("token", (ev) => {
      const { content } = JSON.parse((ev as MessageEvent).data);
      setMarkdown((prev) => prev + content);
    });

    es.addEventListener("complete", () => {
      es.close();
      esRef.current = null;
      setBusy(false);
    });

    // SSE失敗時 → JSONフォールバック
    es.addEventListener("error", () => {
      es.close();
      esRef.current = null;
      fallbackJson();
    });
  }, [url]);

  // JSONフォールバック(ユーザー操作不要で自動実行)
  async function fallbackJson() {
    try {
      const res = await fetch("/api/confluence/convert", {
        method: "POST",
        headers: { "content-type": "application/json" },
        body: JSON.stringify({ url }),
      });
      const data = await res.json();
      setMarkdown(data.markdown || "");
    } finally {
      setBusy(false);
    }
  }

  // クリーンアップ
  useEffect(() => {
    return () => {
      try { esRef.current?.close(); } catch {}
      esRef.current = null;
    };
  }, []);

  return { markdown, busy, start };
}

フォールバックのポイント:

  • SSEのerrorイベントで自動的にJSONフェッチに切り替える
  • ユーザーは切り替わったことを意識しない
  • SSEとJSONで同じデータを返すバックエンドエンドポイントを用意しておく

応用パターン: 並列SSEストリームの協調完了

複数のSSEストリームを同時に開き、すべての完了を待つパターンです。

async function searchMultiple(tasks: SearchTask[]) {
  let remaining = tasks.length;
  const errors: string[] = [];

  const doneOne = () => {
    remaining -= 1;
    if (remaining <= 0) {
      setBusy(false);
      if (errors.length) setError(errors.join(" | "));
    }
  };

  for (const task of tasks) {
    const es = new EventSource(
      `/api/jira/search/stream?fixVersion=${encodeURIComponent(task.version)}&instance=${task.instance}`
    );

    es.addEventListener("issue", (ev) => {
      const issue = JSON.parse((ev as MessageEvent).data);
      addIssue({ ...issue, source: task.label });
    });

    es.addEventListener("complete", () => {
      es.close();
      doneOne();
    });

    es.addEventListener("error", () => {
      es.close();
      // SSE失敗 → JSONフォールバック → 完了カウント
      void fallbackJson(task)
        .then((errMsg) => {
          if (errMsg) errors.push(`${task.label}: ${errMsg}`);
        })
        .finally(doneOne);
    });
  }
}

remainingカウンターパターンがこの設計の核心です。

  • 各ストリームのcompleteまたはerrordoneOne()を呼ぶ
  • remainingが0になったら全体の完了処理を実行
  • Promise.allを使わない理由: SSEのEventSourceはPromiseではなく、completeイベントのコールバックでしか完了を検知できない
  • エラーは配列に蓄積し、最後にまとめて表示

クリーンアップの設計

SSE接続、AbortController、Object URLなど、複数のリソースを管理するコンポーネントでは、各クリーンアップを個別のtry-catchで囲むことが重要です。

useEffect(() => {
  return () => {
    // 各クリーンアップを個別にtry-catch
    // 1つの失敗が他のクリーンアップをブロックしない
    try { esRef.current?.close(); } catch {}
    esRef.current = null;

    try { ocrAbortRef.current?.abort(); } catch {}
    ocrAbortRef.current = null;

    try {
      if (objectUrlRef.current) {
        URL.revokeObjectURL(objectUrlRef.current);
      }
    } catch {}
    objectUrlRef.current = null;
  };
}, []);

なぜ個別にtry-catchするのか:

  • EventSource.close() は既にクローズ済みでもエラーを投げない仕様だが、ブラウザ実装による
  • AbortController.abort() は安全だが、refが意図しない状態になっている可能性がある
  • URL.revokeObjectURL は既に解放済みのURLに対して呼ぶとエラーになりうる
  • 1つのクリーンアップが失敗しても、他のリソースは確実に解放したい

よくあるハマりポイント

1. SSEがバッファリングされて一括で届く

原因: リバースプロキシ(nginx等)がレスポンスをバッファリングしている

対策: レスポンスヘッダーに以下を設定

X-Accel-Buffering: no
cache-control: no-cache
transfer-encoding: chunked

2. BFFでストリーミングが途切れる

原因: Next.jsのRoute Handlerでres.text()res.json()でボディを読み取っている

対策: res.body(ReadableStream)をそのままnew Response(res.body, {...})に渡す

3. コンポーネントアンマウント後にsetStateが呼ばれる

原因: SSEイベントハンドラが、コンポーネントアンマウント後も発火している

対策: useEffectのクリーンアップでEventSource.close()を確実に呼ぶ

4. 並列ストリームの完了判定が難しい

原因: EventSourceはPromiseベースではなく、Promise.allが使えない

対策: remainingカウンターパターンで手動管理

まとめ

SSEストリーミングをプロダクションで安定動作させるために実装したパターンをまとめます。

課題 パターン 実装
SSE接続失敗 JSONフォールバック errorイベントで自動切り替え
プロキシバッファリング ヘッダー設定 X-Accel-Buffering: no
BFFでの中継 ボディ透過転送 res.bodyを読み取らずにそのまま渡す
並列ストリーム完了 remainingカウンター 各ストリーム完了時にデクリメント
リソースリーク 個別try-catchクリーンアップ 1つの失敗が他を阻害しない
イベント構造 型付きSSEイベント event: name + JSON data

SSEは「接続すれば動く」技術ではなく、フォールバック・クリーンアップ・ヘッダー設定をセットで設計する必要があるというのが最大の学びでした。

この記事をシェアする

関連記事