SSEストリーミングにJSON フォールバックを組み合わせる:プロダクションで安定動作させる実装パターン
はじめに
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ストリーミングエンドポイントとJSONエンドポイントの両方を用意します。SSE接続が失敗した場合、ユーザー操作なしでJSON APIに自動フォールバックします。
バックエンド: SSEイベントの構造化
SSEのイベントを生の文字列ではなく、型付きの構造化イベントとして設計します。
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と自然に対応します。
チャットストリーミングのイベントシーケンス

用途に応じてイベント種別を使い分けます。
| イベント名 | 用途 | ペイロード |
|---|---|---|
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でバックエンドのストリーミングレスポンスをそのまま中継します。
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またはerrorでdoneOne()を呼ぶ 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は「接続すれば動く」技術ではなく、フォールバック・クリーンアップ・ヘッダー設定をセットで設計する必要があるというのが最大の学びでした。




