1. 「リアルタイム=WebSocket」という脊髄反射が招く悲劇
「ユーザーのアクションをリアルタイムに反映したい」「LLMのトークン生成を1文字ずつ画面に流したい」「新着通知を即座にポップアップさせたい」。こうした要件が出た瞬間、多くの開発チームは脊髄反射のように WebSocket や Socket.io を技術選定のテーブルに載せます。
かつてWeb開発の入門記事がこぞって「Socket.io で作るリアルタイムチャットチュートリアル」を量産した結果、「リアルタイム通信といえばWebSocket」という強力な固定観念が業界に定着してしまいました。しかし、現場のシニアエンジニアとしてあえて強いオピニオンを突きつけたいと思います。
「あなたのその機能、本当にクライアントからサーバーへの『超高頻度な双方向通信』が必要ですか? 9割の要件は単なる『サーバーからの単方向Push』に過ぎません。」
冷静に要件を分解してみてください。
- チャットアプリのメッセージ送信: クライアントからの送信は、単なる
POST /api/messagesで何の問題もありません。レスポンスで200 OKを受け取れば完了です。 - LLM・生成AIのトークンストリーミング: サーバー側で生成されたテキストがクライアントに向かって順次流れ落ちてくるだけです(100%単方向)。
- 通知・ダッシュボードのメトリクス更新: バックエンドで起きたイベントをブラウザに届けるだけです(100%単方向)。
- 進捗バー(非同期バッチの進捗表示): ジョブのステータス率が順次届くだけです(100%単方向)。
クライアントからサーバーに対して、秒間数十回もの高頻度バイナリフレームを送りつける必要があるのは「FPSや対戦型オンラインゲーム」「Figmaのようなリアルタイム共同編集キャンバス」といった極めて限定的な領域だけです。それ以外の9割のWebアプリケーションにおいて、単方向PushのためにWebSocketを導入することは、「近所のコンビニに牛乳を買いに行くために戦車をチャーターする」ような救いようのない過剰設計(Over-engineering)です。
2. WebSocketが本番インフラに持ち込む「冷酷な現実」
ローカル環境の localhost:3000 で動かす限り、WebSocket は魔法のように快適に見えます。しかし、ひとたびステージングや本番環境のクラスタにデプロイした瞬間、開発者は牙を剥いたインフラの複雑性に直面します。
[HTTP: ステートレスで気楽な世界]
Client ----> [ Load Balancer ] ----> Any Pod A, B, C (誰が受けても同一)
[WebSocket: ステートフルな結合地雷]
Client 1 ===== (永続TCP結合) =====> Pod A
Client 2 ===== (永続TCP結合) =====> Pod B
※ Pod B で起きたイベントを Client 1 に届けるには?
Pod B ----> [ Redis Pub/Sub / NATS ] ----> Pod A ----> Client 1
↑ 外部メッセージブローカーの運用とSPOFリスクが強制的に発生!
① ステートレス性の崩壊と「Redis Pub/Sub 必須化」の罠
通常のHTTPリクエストはステートレスです。ロードバランサーの配下にコンテナ(Pod)が何台あろうと、どのインスタンスがリクエストを処理しても同じ結果を返せます。水平オートスケール(HPA)も極めて安全に行えます。
しかし WebSocket は、クライアントと特定のサーバープロセスが 永続的なTCPコネクションでガチガチに結合 されます。ユーザーAがPod-1に接続し、ユーザーBがPod-2に接続している場合、Pod-2が「ユーザーA宛てのメッセージ」を検知しても直接送ることができません。
これを解決するために、現場では Redis Pub/Sub や RabbitMQ、NATS などの分散メッセージブローカーをインフラに追加 せざるを得なくなります。単に通知を1通送りたかっただけなのに、メッセージミドルウェアのクラスタ設計、メモリ監視、コネクションプール管理、障害時のフェイルオーバーという巨大な運用コストを抱え込むことになるのです。
② デプロイ時の「Thundering Herd(再接続の雷鳴)」
WebSocketを採用したサービスで最も恐ろしいのが「本番デプロイ」です。新しいバージョンのコンテナをローリングアップデートする際、古いPodがシャットダウンされると、そこに接続していた数万〜数十万のWebSocketコネクションが一瞬で切断されます。
切断された全クライアントのJavaScriptが一斉に new WebSocket() で再接続を試みたらどうなるでしょうか? ロードバランサーとサーバーに数十万リクエストの「再接続スパイク(Thundering Herd)」が直撃し、CPU使用率は100%に張り付き、サービス全体が連鎖的にダウンします。ジッター(ランダムな遅延)を導入した指数バックオフ再接続をクライアント側で完璧に組まない限り、デプロイのたびに障害を起こす時限爆弾となります。
③ 謎の無音切断とハートビート実装の泥沼
AWS ALB や Nginx、あるいは企業ネットワークのプロキシは、一定時間通信のないアイドル状態のTCPコネクションを勝手に破棄します。これを防ぐために、アプリケーション層で定期的な ping / pong ハートビートフレームを自前実装し、接続が生きているかを監視し続けなければなりません。
3. なぜ今、SSE(Server-Sent Events)なのか?
これらの地獄をすべて回避し、外部インフラを一切増やさずに単方向リアルタイムPushを実現する決定打が、W3C/WHATWGで標準化されている SSE(Server-Sent Events) です。
① 純粋なHTTPであるという圧倒的アドバンテージ
SSEは、プロトコルのアップグレード(Upgrade: websocket)を一切行いません。クライアントが普通のHTTP GETリクエストを送り、サーバーが Content-Type: text/event-stream というレスポンスヘッダーを返してストリームを開いたままにするだけの、100%純粋なHTTP通信 です。
- 既存の認証がそのまま通用: AuthorizationヘッダーやHttpOnly Cookieが完全にそのまま機能します。WebSocketのように「Cookieが送れないからクエリパラメータにアクセストークンを乗せる」というセキュリティ上のアンチパターンを踏む必要がありません。
- ファイアウォール・プロキシを100%通過: 通常のHTTP/HTTPS通信(ポート80/443)であるため、企業内プロキシやCDNでブロックされる心配がありません。
② HTTP/2 時代における「接続数上限問題」の完全な過去化
古参のエンジニアの中には「SSEはブラウザの同一ドメイン接続数上限(HTTP/1.1では最大6接続)に引っかかるから使えない」と記憶している人がいます。しかし、それは 10年以上前の古い知識 です。
現代のWebでは HTTP/2(およびHTTP/3) が標準です。HTTP/2では単一のTCPコネクション上で複数のリクエスト・レスポンスが多重化(Multiplexing)されます。そのため、SSEストリームを開いたまま別のAPIを呼び出そうが、タブを複数開こうが、接続数制限でブロックされることは一切ありません。
③ ブラウザ標準の EventSource が誇る自動復帰機能
SSEの真骨頂は、ブラウザ標準のJavaScript APIである EventSource にあります。WebSocketでは自前で何十行も書かされていた「切断時の自動再接続」や「未受信データのリカバリ」が、仕様レベルで最初から組み込まれている のです。
[ネットワーク切断が発生した場合の挙動]
1. Wi-Fiが途切れる
2. EventSource が自動的に切断を検知
3. 数秒後、ブラウザが勝手にバックオフをかけて再接続を試みる(JS実装不要!)
4. 再接続時、ブラウザが自動的に `Last-Event-ID: 1042` ヘッダーを送信
5. サーバーは ID: 1043 以降の差分だけをクライアントに流す(データ欠落ゼロ)
外部ライブラリを何ひとつ読み込まず、標準APIだけでこの堅牢性が手に入ります。これが「Web標準の力」です。
4. 外部依存ゼロ: Go標準ライブラリだけで組むSSEブロードキャスター
具体例として、外部ミドルウェア(Redisなど)を一切使わず、Goの標準パッケージ net/http だけで動作する、毎秒数万Pushに耐えうるミニマルなSSEサーバーを書いてみましょう。
package main
import (
"fmt"
"net/http"
"sync"
"time"
)
// SSEBroker: 接続中のクライアントをメモリ上で管理する最小ブローカー
type SSEBroker struct {
clients map[chan string]struct{}
mu sync.RWMutex
}
func NewBroker() *SSEBroker {
return &SSEBroker{
clients: make(map[chan string]struct{}),
}
}
func (b *SSEBroker) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// 1. ストリーミングをサポートしているか検証
flusher, ok := w.(http.Flusher)
if !ok {
http.Error(w, "Streaming unsupported!", http.StatusInternalServerError)
return
}
// 2. SSE必須ヘッダーの設定
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
w.Header().Set("X-Accel-Buffering", "no") // Nginxのリバースプロキシバッファを無効化
// 各クライアント専用のバッファ付きチャネルを生成
messageChan := make(chan string, 16)
b.mu.Lock()
b.clients[messageChan] = struct{}{}
b.mu.Unlock()
// クライアント切断時にマップから確実に登録解除
defer func() {
b.mu.Lock()
delete(b.clients, messageChan)
close(messageChan)
b.mu.Unlock()
}()
// 接続確立を即座にクライアントへフラッシュ
fmt.Fprintf(w, ": connected\n\n")
flusher.Flush()
ctx := r.Context()
for {
select {
case <-ctx.Done():
// ブラウザがタブを閉じるかネットワークが切れたら即座に終了
return
case msg, open := <-messageChan:
if !open {
return
}
// SSE仕様に準拠したフォーマット(data: \n\n)
fmt.Fprintf(w, "data: %s\n\n", msg)
flusher.Flush()
}
}
}
// Broadcast: 全接続中クライアントへ非同期にメッセージを配信
func (b *SSEBroker) Broadcast(msg string) {
b.mu.RLock()
defer b.mu.RUnlock()
for ch := range b.clients {
select {
case ch <- msg:
default:
// 遅いクライアント(Slow Consumer)で他者をブロックしない
}
}
}
外部パッケージのインポートはゼロ。サードパーティの重厚なフレームワークも不要です。http.Flusher を使ってバッファを即時クライアントへ押し出し、r.Context().Done() をリッスンすることで、クライアントが離脱した瞬間にゴルーチンとチャネルをクリーンアップします。リークの心配もありません。
5. クライアント側の実装比較: 200行の自前WS vs 15行のEventSource
フロントエンド側のコードを比較すれば、その圧倒的な開発体験の差は一目瞭然です。
❌ WebSocket で真面目に運用しようとした場合の現実(疑似コード)
// WebSocket: 自前で再接続・生存確認・メッセージパースを書く地獄
class WebSocketClient {
constructor(url) {
this.url = url;
this.reconnectAttempts = 0;
this.connect();
}
connect() {
this.ws = new WebSocket(this.url);
this.ws.onopen = () => {
this.reconnectAttempts = 0;
this.startHeartbeat(); // 定期Pingの開始
};
this.ws.onmessage = (e) => {
const msg = JSON.parse(e.data);
if (msg.type === 'pong') return; // 心拍応答
handleEvent(msg);
};
this.ws.onclose = () => {
this.stopHeartbeat();
// 指数バックオフ計算とジッターの追加...
const timeout = Math.min(1000 * (2 ** this.reconnectAttempts), 30000);
setTimeout(() => {
this.reconnectAttempts++;
this.connect();
}, timeout + Math.random() * 1000);
};
this.ws.onerror = () => this.ws.close();
}
}
⭕ SSE(ブラウザ標準 EventSource)を使った場合
// SSE: これだけで自動再接続・切断復旧・エラーハンドリングが完結する
const evtSource = new EventSource('/api/events');
evtSource.onmessage = (event) => {
const data = JSON.parse(event.data);
console.log('新着通知を受信:', data);
};
evtSource.onerror = (err) => {
// ブラウザが裏で勝手に再接続を試みているため、
// UI上のオフラインバッジ表示などに専念できる
console.warn('ストリーム一時切断(自動再接続待機中...)');
};
ライブラリのインストールも、再接続タイマーの管理も、Ping/Pongの送受信も一切不要です。ブラウザネイティブの堅牢なC++実装がバックグラウンドで接続維持を担当してくれます。
6. 技術選定マトリクス: いつ何を選ぶべきか?
誤解のないように強調しておきますが、筆者は「WebSocketをこの世から根絶せよ」と主張しているわけではありません。技術選定の本質は「適材適所」です。しかし、現場の9割のケースで判断基準が狂っていることを指摘したいのです。
| 比較項目 | SSE(Server-Sent Events) | WebSocket |
|---|---|---|
| 通信方向 | サーバー → クライアント(単方向) | 双方向(全二重) |
| プロトコル | 純粋な HTTP/1.1, HTTP/2, HTTP/3 | 独自の ws://, wss:// |
| ブラウザ再接続 | ブラウザ標準で自動再接続(ID復帰あり) | 自前で再接続ループの実装が必須 |
| インフラ親和性 | ステートレスLB・CDN・標準認証と完全適合 | Sticky SessionやRedis Pub/Subがほぼ必須 |
| プロキシ透過性 | 高い(企業Firewallもほぼ通過) | 企業プロキシ等で遮断される事故あり |
| 最適な用途 | AIストリーミング、通知、進捗バー、フィード更新 | 対戦ゲーム、画面共同編集(ミリ秒同期) |
判断基準は極めてシンプルです。
- クライアントからサーバーへのデータ送信頻度が「人間がキーボードを叩く程度(秒間数回以下)」なら:
迷わず 「通常のHTTP POST」+「SSEによるPush」 のハイブリッド構成を選んでください。インフラの複雑性を1/10に圧縮できます。 - クライアントからサーバーへ「毎秒30〜60回のバイナリ入力(マウス座標・コントローラー入力)」を連続ストリーミングするなら:
ここで初めて WebSocket の出番です。その複雑性とインフラコストを支払う価値があります。
7. まとめ: 「枯れた標準」を使い倒すエンジニアの矜持
「リアルタイム=WebSocket」という安易な連想は、多くのプロジェクトに不要なインフラコストと運用負荷という名の負債を背負わせてきました。
技術の選定において最も重要なのは、新しいものや派手な名前に飛びつくことではありません。「要求されている課題の本質を極限まで見極め、それを満たす最もシンプルで壊れにくい標準仕様を選ぶこと」 です。
HTTP/2・HTTP/3が普及した現代において、SSEはまさにその「最もエレガントな解答」です。次にリアルタイム要件が降ってきたときは、ぜひチームの仲間に問いかけてみてください。
「その機能、本当にWebSocketが必要ですか? SSEなら、外部インフラも自前再接続コードもゼロで今すぐ本番稼働できますよ。」