「リアルタイムにしたい」——要件としては一言です。けれど実装に落とそうとした瞬間、選択肢が一気に開きます。WebSocket でつなぐのか。SSE でサーバーから流すのか。ポーリングで取りに行くのか。それとも、書き込みを冪等にしてキャッシュを無効化し、短い間隔で取り直す「準リアルタイム」で十分なのか。
そして、ここで多くの設計が最初の一歩を踏み外します。「リアルタイム=WebSocket必須」という思い込みです。確かに WebSocket は双方向・低遅延の王様ですが、その代わりにブロードキャストの配信コスト・障害時の脆さ・順序とリトライの難しさを抱え込みます。実際の要件の多くは、SSE(サーバー→クライアントの一方向プッシュ)や、冪等な書き込み+無効化+短間隔再取得で、より安く・より壊れにくく実現できます。
この記事は、「どの方式を選ぶか」を要件から導く意思決定フレームワークです。4つの選択肢を比較表で並べ、それぞれの公式仕様に忠実なコードを示し、最後に「迷ったときの早見表」で締めます。題材として、私がアマチュア野球向けに構築した複数人同時スコアリングのリアルタイム試合記録アプリで、あえて WebSocket を採らなかった設計判断も交えます。
この記事のルール:WebSocket / SSE / EventSource の API・ワイヤ形式は MDN(Web標準ドキュメント・2026年6月時点) に基づきます。仕様は更新され得るため、本番投入前に必ず公式ドキュメントで最新値を確認してください。コードは実運用で使える形に整えていますが、認証トークンやシークレットは環境変数前提です(ハードコード厳禁)。実プロジェクトの設計判断は、その旨を明記します。
0. 最初に:意思決定テーブル(これが本記事の背骨)
細かい実装に入る前に、まず要件を6つの軸に割り、4つの選択肢を当てる。これが一番大事な作業です。
| 判断軸 | WebSocket | SSE(EventSource) | ポーリング / ロングポーリング | 楽観更新+無効化(準リアルタイム) |
|---|---|---|---|---|
| 通信方向 | 双方向(全二重) | 単方向(サーバー→クライアント) | 単方向(クライアント主導の取得) | 単方向(取得)+ローカル先行反映 |
| 想定更新頻度 | 高頻度(ms〜秒) | 中〜高頻度のプッシュ | 低〜中頻度 | 高頻度書き込み×準リアルタイム反映 |
| 順序保証 | 接続単位では順序あり。複数接続/再接続をまたぐと自前 | イベントは順序送出。id で再開可 | リクエスト単位。順序は自前 | 冪等キーで順序非依存に設計(収束保証) |
| 障害耐性 | 切断検知・再接続・重複/欠落を全部自前 | 自動再接続+Last-Event-IDで再開(標準装備) | 取りこぼしても次の取得で回復(堅牢) | キュー+バックオフ+冪等で最も壊れにくい |
| スケール/コスト | 常時接続を全台分維持。ブロードキャストが高コスト | 接続は維持するが配信ロジックが単純。プロキシ相性に注意 | サーバーは素のHTTP。頻度×台数でコスト増 | 既存のCRUD APIに乗る。追加インフラほぼ不要 |
| 実装の複雑さ | 高(プロトコル別・接続管理・状態) | 中(標準仕様に乗れば素直) | 低 | 中(冪等設計が要・だが運用は楽) |
この表を一行で要約すると、こうなります。
真の双方向・低遅延が要るなら WebSocket。サーバーからの一方向プッシュなら SSE。低頻度なら素のポーリング。高頻度書き込み×オフライン×順序が重要なら、冪等キー+準リアルタイム。
「リアルタイムにしたい」という言葉が指す実体は、たいてい右3列のどれかです。WebSocket は最後に検討すべき選択肢であって、最初に飛びつくものではありません。
1. WebSocket:双方向・低遅延の王様。ただし運用は全部自前
1.1 WebSocket は「HTTPではない別プロトコル」
まず性質を正確に押さえます。MDN の定義はこうです。
The WebSocket API makes it possible to open a two-way interactive communication session between the user's browser and a server. With this API, you can send messages to a server and receive responses without having to poll the server for a reply.
ポイントは2つ。(1) 双方向(two-way / 全二重)で、(2) ポーリング不要。クライアントとサーバーが対等にメッセージを送り合えます。スキームは ws://(平文)/ wss://(TLS)。HTTP でハンドシェイクを開始したあと、Sec-WebSocket-Key / Sec-WebSocket-Accept を交換して専用のプロトコルにアップグレードします。つまり、リクエスト/レスポンスの世界とは別のレイヤーで動きます。
1.2 最小例とライフサイクル
MDN が定義する WebSocket のライフサイクルは、4つのイベントで表現されます。
open— 接続が開いたときmessage— データを受信したとき(MessageEvent)error— エラーで接続が閉じられたときclose— 接続が閉じたとき(CloseEvent)
接続状態は readyState で、CONNECTING(0) / OPEN(1) / CLOSING(2) / CLOSED(3) の4状態を取ります。送信は send()、切断は close(code, reason) です。
// WebSocket の最小例(ブラウザ)。これ自体は素直だが、本番はここからが本番。
const socket = new WebSocket("wss://example.com/ws");
socket.addEventListener("open", () => {
// OPEN(1) になってからしか送れない。CONNECTING(0) 中の send は失敗する。
socket.send(JSON.stringify({ type: "subscribe", room: "game:42" }));
});
socket.addEventListener("message", (event: MessageEvent) => {
const msg = JSON.parse(event.data);
applyServerMessage(msg);
});
socket.addEventListener("error", () => {
// error の詳細は意図的に乏しい(セキュリティ上)。close とセットで扱う。
});
socket.addEventListener("close", (event: CloseEvent) => {
// 正常終了(event.wasClean)か異常切断かで再接続方針を変える。
scheduleReconnect(event.code);
});
ここまでは教科書どおり。問題は「教科書に載っていない部分」が運用の9割だという点です。
1.3 本番で自前実装が必要になるもの(=WebSocketの真のコスト)
WebSocket を選ぶということは、次を全部自分で書くと決めることです。
// 本番WebSocketに最低限必要な「自前装備」のスケッチ。
// 標準APIは接続を開くだけで、下記は何も面倒を見てくれない。
class ResilientSocket {
private socket: WebSocket | null = null;
private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
private reconnectAttempt = 0;
private lastSeq = 0; // 受信済みの最終シーケンス(順序・重複対策)
connect(url: string) {
const socket = new WebSocket(url);
this.socket = socket;
socket.addEventListener("open", () => {
this.reconnectAttempt = 0;
this.startHeartbeat();
// 再接続時は「どこまで受け取ったか」をサーバーに伝えて差分を要求する。
socket.send(JSON.stringify({ type: "resume", afterSeq: this.lastSeq }));
});
socket.addEventListener("message", (e) => {
const msg = JSON.parse(e.data);
if (msg.type === "pong") return; // ハートビート応答
if (msg.seq <= this.lastSeq) return; // 重複は破棄(at-least-once対策)
this.lastSeq = msg.seq; // 連番でギャップ検出も可能
applyServerMessage(msg);
});
socket.addEventListener("close", () => {
this.stopHeartbeat();
this.scheduleReconnect(url); // 指数バックオフで再接続
});
}
private startHeartbeat() {
// 中間プロキシは無通信のWS接続を黙って切る。明示的なping/pongで生存を保つ。
this.heartbeatTimer = setInterval(() => {
if (this.socket?.readyState === WebSocket.OPEN) {
this.socket.send(JSON.stringify({ type: "ping" }));
}
}, 25_000);
}
private stopHeartbeat() {
if (this.heartbeatTimer) clearInterval(this.heartbeatTimer);
}
private scheduleReconnect(url: string) {
const delay = Math.min(1000 * 2 ** this.reconnectAttempt, 30_000);
this.reconnectAttempt += 1;
setTimeout(() => this.connect(url), delay + Math.random() * 1000); // ジッタ
}
}
整理すると、WebSocket を本番運用するために自前で抱える負債はこれだけあります。
- 再接続:標準APIに自動再接続はない。切れたら自分で張り直す(指数バックオフ+ジッタ)。
- ハートビート:中間のロードバランサ/プロキシはアイドルなWS接続を切る。
ping/pongで生かし続ける。 - 順序と重複:再接続をまたぐと「どこまで届いたか」が曖昧になる。シーケンス番号と冪等処理が要る。
- ブロードキャストのスケール:「同じ部屋の全員に配る」をサーバー側で持つ。台数が増えると、誰がどの接続を持つかを跨インスタンスで共有する仕組み(Redis Pub/Sub 等)が必要になり、ここが配信コストの主因。
- 認証:ブラウザの
WebSocketコンストラクタはカスタムヘッダを付けられない。トークンは接続後の最初のメッセージで渡すか、短命チケットをクエリに載せる設計にする(→第5章)。
WebSocket を選ぶ正しい場面:チャット、オンラインゲーム、協調カーソル(同じ図形を同時にドラッグ)など、クライアントからも高頻度に送る・遅延がUXを左右するケース。逆に「サーバーの状態が変わったらUIに知らせたいだけ」なら、双方向は過剰です。
2. SSE(Server-Sent Events):サーバー→クライアントの一方向プッシュを、標準で
2.1 SSEは「再接続と再開が標準装備」
SSE の本質を MDN はこう定義します。EventSource は——
Unidirectional: Data flows one direction only—from server to client. Unlike WebSockets,
EventSourcecannot send data from client to server in message form.
一方向。ただし、その代わりに WebSocket で自前実装していた自動再接続とLast-Event-IDによる再開が、仕様として最初から入っています。これは大きい。進捗通知・通知ストリーム・ライブ更新のように「サーバーが知らせる」用途では、SSEのほうが圧倒的に楽です。
2.2 SSEのワイヤ形式(MDNの正確な定義)
SSE は Content-Type: text/event-stream のプレーンテキストストリームです。フィールド行を並べ、空行(改行2つ)でメッセージを区切る。MDN が定義するフィールドは4つ。
| フィールド | 意味 |
|---|---|
data: | メッセージ本文。連続する data: 行は改行を挟んで連結され、末尾の改行は除去される |
event: | イベント名。省略すると message イベントとして受信される |
id: | EventSource の last event ID を設定する。再接続時の再開に使う |
retry: | 再接続までの待ち時間をミリ秒で指定(整数のみ。非整数は無視) |
行頭が : の行はコメントで、無視されます(接続維持のキープアライブに使える)。MDN のサンプルストリームはこうです。
: this is a comment
event: userconnect
data: {"username": "bobby", "time": "02:33:48"}
data: Here's a system message
data: with two lines
event: usermessage
data: {"username": "bobby", "text": "Hi everyone."}
retry: 10000
複数行の data: は "another message\nwith two lines" のように改行連結されます。この形式さえ守れば、ブラウザの EventSource がパース・再接続・再開を全部やってくれる——ここがSSEの旨味です。
2.3 SSEサーバー:Next.js Route Handler で ReadableStream
Next.js の App Router なら、Route Handler から ReadableStream を返すだけでSSEになります。ワイヤ形式を正しく組み立てるのが唯一の仕事です。
// app/api/jobs/[id]/stream/route.ts
// 長時間ジョブの進捗を SSE で準リアルタイムに流す。
import type { NextRequest } from "next/server";
export const runtime = "nodejs";
export const dynamic = "force-dynamic"; // ストリーミングはキャッシュさせない
// SSE 1メッセージを組み立てる小さな純関数(SRP:整形だけを担当)。
function sseEvent(opts: {
data: unknown;
event?: string;
id?: string;
retry?: number;
}): string {
const lines: string[] = [];
if (opts.retry !== undefined) lines.push(`retry: ${opts.retry}`);
if (opts.id !== undefined) lines.push(`id: ${opts.id}`);
if (opts.event !== undefined) lines.push(`event: ${opts.event}`);
// 改行を含む本文は data: 行を分割する(MDNの連結規則に合わせる)。
const payload = JSON.stringify(opts.data);
for (const line of payload.split("\n")) lines.push(`data: ${line}`);
return lines.join("\n") + "\n\n"; // 空行(\n\n)でメッセージを区切る
}
export async function GET(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
const { id } = await params;
// 切断後の再開:ブラウザは再接続時に Last-Event-ID ヘッダを送ってくる。
const lastId = req.headers.get("Last-Event-ID");
const encoder = new TextEncoder();
const stream = new ReadableStream({
async start(controller) {
const send = (s: string) => controller.enqueue(encoder.encode(s));
// クライアントに再接続間隔を指示(瞬断時の張り直しを穏やかに)。
send(sseEvent({ event: "ready", data: { id }, retry: 5_000 }));
// 既知の進捗を resume:lastId 以降だけを送る設計にする。
for await (const p of watchJobProgress(id, lastId)) {
// id を必ず振る → 切断しても EventSource が続きから再開できる。
send(sseEvent({ event: "progress", id: String(p.seq), data: p }));
if (p.percent >= 100) break;
}
send(sseEvent({ event: "done", data: { id } }));
controller.close();
},
cancel() {
// クライアント切断時のクリーンアップ(購読解除など)はここで。
},
});
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache, no-transform", // プロキシのバッファ抑止
Connection: "keep-alive",
"X-Accel-Buffering": "no", // nginx等の中間バッファを無効化
},
});
}
ここで重要なのは、X-Accel-Buffering: no と Cache-Control: no-transform。**SSEはプロキシがレスポンスをバッファすると「リアルタイムでなくなる」**ため、中間層のバッファ抑止が運用の落とし穴です。WebSocketのアップグレードを通さないインフラはあっても、SSEは素のHTTPなので大半の環境を素通りします(これも採用理由になり得る)。
2.4 SSEクライアント:EventSource(自動再接続・Last-Event-ID)
クライアントは EventSource をnewするだけ。MDNの定義どおり、readyState は CONNECTING(0) / OPEN(1) / CLOSED(2)、イベントは open / message / error、名前付きイベントは addEventListener で受けます。
// 進捗SSEを購読する。自動再接続と Last-Event-ID 再開は EventSource が面倒を見る。
export function subscribeJobProgress(
jobId: string,
onProgress: (p: { percent: number; seq: number }) => void,
): () => void {
const es = new EventSource(`/api/jobs/${jobId}/stream`, {
withCredentials: true, // Cookie認証を流す(CORSクレデンシャル)
});
// 名前付きイベント(サーバーの event: progress に対応)。
es.addEventListener("progress", (e) => {
onProgress(JSON.parse((e as MessageEvent).data));
});
es.addEventListener("done", () => es.close()); // 完了で明示クローズ=再接続を止める
// error は「切断」も含む。EventSource は自動で再接続を試みるので、
// ここで毎回 close() しないこと(再接続を殺してしまう)。
es.addEventListener("error", () => {
if (es.readyState === EventSource.CLOSED) {
// 本当に終了した場合だけ後始末。CONNECTING(0) なら自動再接続中。
}
});
return () => es.close(); // 呼び出し側のクリーンアップ
}
SSEのアンチパターン:
errorハンドラで毎回es.close()を呼ぶこと。MDNの仕様上、errorは一時的な切断でも発火し、EventSourceは放っておけば自動で再接続します。ここでclose()すると標準の回復機構を自分で殺すことになります。readyState === CLOSEDのときだけ後始末する、が正解です。
2.5 実プロジェクト:放送局AIプラットフォームの進捗配信
国内大手放送事業者向けの社内AIプラットフォームでは、長時間AIジョブの進捗をこのSSE方式でUIに準リアルタイム配信しました。バックエンドは Firestore のスナップショット購読でジョブ状態の変化を拾い、それをSSEのイベントとしてブラウザへ流す構成です。
ここで一つ、地味だが効いた工夫があります。進捗の逆行を防ぐ単調増加の保証です。分散処理だと、遅れて届いたイベントで「80% → 60%」と進捗バーが巻き戻ることが起きます。これはUXとして最悪なので、resolve_monotonic_progress_percent で「いま表示中の値より小さい進捗は無視(max を取る)」というルールを置き、進捗は単調増加でしか動かないことを保証しました。順序が完全保証されないチャネルでも、値の意味(単調増加)で順序問題を吸収するという考え方です。これは次章の「冪等キーで順序非依存にする」と同じ思想の別表現です。
3. ポーリング / ロングポーリング:低頻度なら、これが一番堅い
意外に思われますが、更新頻度が低いなら素のポーリングが最も堅牢でコスト効率も良いことが多い。常時接続を維持しないので、サーバーはステートレスな素のHTTPで済み、取りこぼしても次の取得で自然に回復します。
// 低頻度更新の準リアルタイム取得。指数的でない素朴な間隔で十分なことが多い。
export function pollResource<T>(
url: string,
onData: (data: T) => void,
intervalMs = 5_000,
): () => void {
let stopped = false;
let timer: ReturnType<typeof setTimeout>;
const tick = async () => {
if (stopped) return;
try {
const res = await fetch(url, { cache: "no-store" });
if (res.ok) onData(await res.json());
} finally {
// タブが非表示なら間隔を伸ばす(無駄な取得とコストを削る)。
const delay = document.hidden ? intervalMs * 4 : intervalMs;
timer = setTimeout(tick, delay);
}
};
tick();
return () => {
stopped = true;
clearTimeout(timer);
};
}
判断の目安はこうです。
- 数秒〜数分に1回の更新で十分(ダッシュボード、ステータス表示、在庫数)→ 素のポーリング。
document.hiddenで間引けばコストも下がる。 - 「変化があったら即」かつ低頻度で、コネクションを温存したい → ロングポーリング(サーバーが変化まで応答を保留)。ただしSSEがある今、新規ならSSEのほうが筋が良いことが多い。
- TanStack Query を使っているなら
refetchIntervalで済む。専用のリアルタイム機構を足す前に、まずこれで足りないか疑う(YAGNI)。
「リアルタイム感」の正体が「数秒の遅延は許容できる定期更新」なら、WebSocketもSSEも要りません。一番安く要件を満たす線を最初に引く——これが設計者の仕事です。
4. 楽観更新+冪等キー+無効化:高頻度書き込み×オフライン×順序、の最適解
ここからが、この記事で一番伝えたい話です。実プロジェクトであえてWebSocketのブロードキャストを採らなかった、その代わりの設計です。
4.1 要件:WebSocketが「向いていない」ケースだった
アマチュア野球向けのリアルタイム試合記録アプリ(Expo + Next.js + Supabase のモノレポ)は、複数人が同じ試合を同時にスコアリングします。要件を分解すると:
- 高頻度の書き込み:投球・打席が秒単位で増える。
- 電波の悪い球場:オフラインが頻発する。端末が圏外でも記録は止められない。
- 順序が重要:イニングの進行、打席内の投球順は崩れてはいけない。
この要件に WebSocket のブロードキャストを当てると、こうなります。高頻度入力を全員に配るための配信コスト、圏外で切れると壊れる障害時の脆さ、再接続をまたぐと崩れる順序の問題——WebSocketが最も苦手とする三重苦を、わざわざ抱え込むことになる。だから採らなかった。
4.2 採った設計:決定的な冪等キー
代わりの核は、決定的に決まる冪等キーです。「同じ操作なら、誰が・何度送っても、同じキーになる」ように設計します。
- 打席:
game:{id}:inn:{回}:{表裏}:seq:{n} - 投球:
ab:{打席id}:pitch:{n}
このキーをDBの一意制約 (recording_team_id, idempotency_key) に効かせ、upsert(ignoreDuplicates) で重複を吸収します。順序非依存・リトライ安全になり、再送が何度起きても収束します。
// 投球の記録。冪等キー → upsert(ignoreDuplicates) で重複を吸収し、収束させる。
type Pitch = {
atBatId: string;
pitchNo: number;
result: "ball" | "strike" | "in_play";
recordingTeamId: string;
};
// 同じ投球は、何度送っても必ず同じキーになる(決定的)。
function pitchIdempotencyKey(p: Pitch): string {
return `ab:${p.atBatId}:pitch:${p.pitchNo}`;
}
async function recordPitch(supabase: SupabaseClient, p: Pitch): Promise<void> {
const idempotencyKey = pitchIdempotencyKey(p);
// (recording_team_id, idempotency_key) の一意制約に乗せ、
// 重複は「無視」する。再送・同時送信・再起動をまたいでも衝突しない。
const { error } = await supabase
.from("pitches")
.upsert(
{ ...p, idempotency_key: idempotencyKey, recording_team_id: p.recordingTeamId },
{ onConflict: "recording_team_id,idempotency_key", ignoreDuplicates: true },
);
if (error) throw error;
}
ごく稀に「両チームの記録係が同じ事象を別の値で入れた」競合が起きたときだけ、権限スコープ付きのRPCで「自チームの行」を解決します。つまり、99%は一意制約で静かに吸収し、本当の競合だけを明示的に解く——複雑さを必要な場所に閉じ込める設計です(KISS/SRP)。
4.3 オフラインファースト:楽観更新+永続キュー+ドレインワーカー
端末側はオフライン前提で組みます。まず楽観的にローカル反映し、送信は永続キューに積む。単一のドレインワーカーが指数バックオフで安全に送る。
// オフラインファーストな送信。UIは即反映、送信はキュー経由でバックオフ送出。
type QueuedWrite = { key: string; payload: Pitch; attempts: number };
class WriteQueue {
private queue: QueuedWrite[] = [];
private draining = false;
// 1) 楽観的にUIへ即反映し、2) 永続キューに積むだけ。圏外でも止まらない。
enqueue(p: Pitch) {
applyOptimistic(p); // ローカルストアに先行反映(UIは待たせない)
this.queue.push({ key: pitchIdempotencyKey(p), payload: p, attempts: 0 });
void this.drain();
}
// 単一のドレインワーカー。並行送信で順序を乱さないため、必ず1本に絞る。
private async drain() {
if (this.draining) return;
this.draining = true;
try {
while (this.queue.length > 0) {
const item = this.queue[0];
try {
await recordPitch(supabase, item.payload); // 冪等なので再送安全
this.queue.shift(); // 成功したものだけ外す
} catch {
item.attempts += 1;
const backoff = Math.min(1000 * 2 ** item.attempts, 30_000);
await sleep(backoff + Math.random() * 500); // ジッタ付き指数バックオフ
}
}
} finally {
this.draining = false;
}
}
}
ドレインワーカーを1本に絞るのが要点です。並行で送ると順序が乱れますが、直列なら投球順が保たれます。そして送信は冪等なので、再起動・操作位置のズレ・重複送信をまたいでもキーで衝突せず収束します。「順序保証」を、接続レイヤーではなく**データの形(冪等キー+直列ドレイン)**で実現しているわけです。
4.4 クライアント間反映:ミューテーション駆動の無効化+短間隔再取得
「他端末の記録を自分の画面にどう映すか」は、ここまで来ると素直に準リアルタイムで十分です。自分の書き込み(ミューテーション)が成功したらキャッシュを無効化し、加えて短い間隔で取り直す。game_states.version 列で楽観ロックを掛け、古いバージョンへの上書きを弾きます。
// TanStack Query 例:ミューテーション成功で無効化+短間隔の再取得(準リアルタイム)。
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: (p: Pitch) => recordPitch(supabase, p),
// 楽観更新:成功を待たずUIへ反映し、失敗時はロールバック。
onMutate: async (p) => {
await queryClient.cancelQueries({ queryKey: ["game", p.atBatId] });
const prev = queryClient.getQueryData(["game", p.atBatId]);
queryClient.setQueryData(["game", p.atBatId], (old) => applyPitch(old, p));
return { prev }; // ロールバック用スナップショット
},
onError: (_e, p, ctx) => {
queryClient.setQueryData(["game", p.atBatId], ctx?.prev); // 失敗→巻き戻し
},
onSettled: (_d, _e, p) => {
// 自分の書き込み後に無効化 → 正の値で取り直す(自分の楽観値を確定/訂正)。
queryClient.invalidateQueries({ queryKey: ["game", p.atBatId] });
},
});
// 他端末の更新を映す準リアルタイム:短間隔ポーリング。version で楽観ロック。
useQuery({
queryKey: ["game", gameId],
queryFn: () => fetchGameState(gameId),
refetchInterval: 3_000, // 3秒。電波が悪い前提なので欲張らない
});
これで、WebSocketのブロードキャストを1本も張らずに「複数人同時編集が、オフラインに強く、順序が崩れず、他端末の更新も数秒で映る」を実現できました。配信インフラを持たない分、障害点も少なく、運用が圧倒的に楽です。
この章の一般化:高頻度書き込みの同期問題は、「全員に即配る(push)」より「冪等に書く+取り直す(pull)」のほうが、オフライン・順序・障害の三点で堅い。WebSocketは"配る"のは得意でも"壊れない"のは自前。冪等設計は"壊れにくさ"を最初から内蔵します。
5. 本番設計:認証・順序・再接続・スケール・可観測性
方式を選んだあとに必ず詰まる、横断的な論点をまとめます。
5.1 認証:SSE / WebSocket でトークンをどう渡すか
ブラウザの標準APIには、カスタムHTTPヘッダを付けられないという共通の制約があります。new WebSocket(url) も new EventSource(url) も Authorization: Bearer ... を直接は付けられません。対処は方式で異なります。
- SSE:
withCredentials: trueでCookieベースのセッションを流すのが王道。サーバー側でCookieを検証すれば、ヘッダ問題は起きません(同一サイトなら最も素直)。 - WebSocket:Cookieは送られるが、短命チケット方式が安全。先に通常のHTTPで「数十秒だけ有効な接続チケット」を発行し、それを
wss://.../ws?ticket=...で渡す。長命トークンをURLに載せない(ログ・履歴に残る)。 - 共通原則:トークンは必ずサーバー側で検証し、購読対象(部屋・ジョブ・チーム)への認可スコープも毎接続で確認する。前章の試合アプリで競合解決RPCに「権限スコープ」を付けたのと同じ思想です。
5.2 順序保証:チャネルに頼らず、データで担保する
順序は接続レイヤーで保証しようとすると脆くなります。再接続・複数接続・分散処理で簡単に崩れるからです。実プロジェクトで効いた2つの型:
- 冪等キー+直列処理(試合アプリ):順序非依存に設計し、キーで収束させる。
- 単調増加で吸収(放送局):
resolve_monotonic_progress_percentのように「値の意味」で逆行を弾く。
どちらも「順序問題をデータの形で吸収する」点が共通です。SSEの id: / Last-Event-ID は再開の助けになりますが、それだけに依存せず、データ側でも崩れない設計にするのが本番品質です。
5.3 再接続・スケール・コスト
- 再接続:SSEは標準装備(
retry:で間隔指示)。WebSocketは自前(指数バックオフ+ジッタ+ハートビート)。これだけでSSEの運用負荷はWebSocketの数分の一。 - スケール:WebSocketのブロードキャストは、複数インスタンス間で「誰がどの接続を持つか」を共有する仕組み(Pub/Sub)が要り、ここがコストと障害点の主因。SSEは配信ロジックが単純で、準リアルタイム方式は既存CRUDに乗るので追加インフラがほぼ要らない。
- コスト:常時接続×台数の維持費を、本当に払う価値があるか。「数秒の遅延が許容できる」なら、その費用は払わないのが正解。
5.4 可観測性:何を必ず記録するか
リアルタイム系は「動いている/いない」が見えにくい。メタデータを構造化ログで残すのが必須です。
- 接続イベント:接続数・切断率・平均接続時間・再接続回数。
- メッセージ:送出/受信レート、ドロップ数、シーケンスギャップ検出数。
- 準リアルタイム:キュー滞留数・ドレイン失敗率・バックオフ発生回数。
- PIIは本文ログに残さない(試合の選手名・通知の本文など)。記録するのはIDとメタデータまで。
6. a11y / UX:楽観更新とライブ領域の作法
リアルタイムUIは、速さと正直さの両立が肝です。
- 楽観更新の見せ方:成功を待たず反映する以上、「まだ確定していない」状態を視覚的に示す(薄い色・スピナー・"送信中"バッジ)。失敗時は静かに巻き戻すのではなく、ロールバックを明示し再試行導線を出す。ユーザーが「入れたはずの記録が消えた」と感じると信頼を失います。
- ライブ領域は
aria-live:スコアや進捗が動的に変わる領域にはaria-live="polite"(割り込まない更新)を付け、スクリーンリーダーに変化を読み上げさせる。緊急度の高い通知はaria-live="assertive"。リアルタイムUIほど、視覚に頼れない利用者への配慮が要ります。 - 準リアルタイムの正直さ:3秒間隔なら「最大3秒遅れる」前提でUIを設計する。「リアルタイム」と謳って数秒遅れると不信を招くので、過剰な約束をしない。
7. まとめ:選定チートシート
迷ったときの早見表です。上から順に「より軽い選択肢で足りないか」を疑うのがコツ。
- 数秒〜数分の定期更新で十分 → ポーリング(
refetchInterval/document.hiddenで間引き)。専用機構を足す前にこれを疑う。 - サーバー→クライアントの一方向プッシュ(進捗・通知・ライブ更新) → SSE。自動再接続+Last-Event-IDが標準装備。
text/event-stream、data:/event:/id:/retry:、空行区切り。プロキシのバッファ抑止を忘れずに。 - 高頻度書き込み × オフライン × 順序が重要 → 冪等キー+楽観更新+無効化(準リアルタイム)。順序は接続でなくデータの形で担保。配信インフラを持たない分、最も壊れにくい。
- 真の双方向・低遅延が必須(チャット・ゲーム・協調カーソル) → WebSocket。ただし再接続・ハートビート・順序・重複・ブロードキャストのスケールは全部自前と覚悟する。
- 共通装備:トークンはサーバー検証+認可スコープ、順序はデータで吸収(冪等キー or 単調増加)、可観測性はメタデータのみ(PIIを出さない)、楽観更新は失敗時ロールバックと
aria-live。
「リアルタイムにしたい」という一言の裏には、双方向性・更新頻度・順序・障害耐性・コストのトレードオフが隠れています。それを要件から解きほぐし、一番安く・一番壊れにくい線を引くのが設計の本体です。
私は、電波の悪い球場で複数人が同時にスコアリングする試合記録アプリを、WebSocketのブロードキャストに頼らず、冪等な準リアルタイム同期でオフライン耐性を持たせて作りました(一人 × 生成AIで、設計から実装・検証まで一気通貫で)。同じ放送局案件では、長時間AIジョブの進捗をSSE+単調増加保証でUIに届けています。
「うちのこの機能、本当にWebSocketが要るのか?」——その問いから一緒に整理できます。 要件の棚卸し段階からでも、お気軽にご相談ください。
参考(公式ドキュメント)
- WebSocket API(MDN) — 双方向通信・ハンドシェイク・イベントモデル
- WebSocket インターフェース(MDN) —
open/message/error/close・send()・close()・readyState - Using server-sent events(MDN) — SSEワイヤ形式(
data:/event:/id:/retry:)・text/event-stream・自動再接続 - EventSource(MDN) — コンストラクタ・
readyState・withCredentials・名前付きイベント・close()