はじめに
「外部サービスの状態変化をリアルタイムにUIへ反映したい」
Web開発をしていると、こういう場面に出くわす。このとき選択肢は主に3つある。
- ポーリング — クライアントが定期的にサーバーへ聞きに行く
- Webhook — 外部サービスがイベント発生時にサーバーへ通知してくる
- SSE(Server-Sent Events) — サーバーがクライアントへ継続的にPushする
どれも「データを届ける」手段だが、選び方を間違えると、本来0行で済むところに500行のコードが必要になる。
この記事では、筆者が実際にWebhookで十分だったところにSSEを採用して痛い目を見た経験をもとに、3パターンの選定基準を整理する。
この記事でわかること:
- Webhook・ポーリング・SSEの仕組みと違い
- ユースケースごとの選び方
- 「技術選定の前にUXを考えろ」という教訓
3パターンを一言で整理する
| パターン | 通信の方向 | 一言で言うと |
|---|---|---|
| ポーリング | Client → Server | 「新しいデータある?」を繰り返し聞く |
| Webhook | Server → Server | 「変わったよ」と通知が来る |
| SSE | Server → Client | サーバーがクライアントに継続的に流す |
Pull型(ポーリング)とPush型(Webhook, SSE)という分類で考えるとわかりやすい。
失敗談: SSEでOver-engineeringした話
やりたかったこと
外部SaaSと連携して、非同期ジョブの処理結果をプロダクトに取り込む機能を作っていた。
- ユーザーが「処理したいジョブ」を事前に予約する
- 外部SaaS側で処理が完了すると、結果が生成される
- その結果をプロダクト側に取り込んで表示する
外部SaaSはWebhookでイベントを送ってくれる(処理開始・完了など)。ここまでは自然な設計だった。
判断ミス1: 「リアルタイムに見せたい」という思い込み
ジョブの状態遷移をフロントへリアルタイムに反映したいと考え、SSEを採用した。
「ステータスが pending → processing → completed と変わる様子をユーザーにリアルタイムに見せたらUX良くない?」 — これが判断の出発点だった。
構成はこうなった:
SSEを選んだ結果、必要になったもの
「ステータスをリアルタイムに見せたい」というたった1つの要望のために、以下が全部必要になった:
- インメモリ状態ストア — ジョブの状態をメモリに保持し、購読者にブロードキャスト
- マルチインスタンス同期 — 複数コンテナが動く環境では、あるインスタンスに来たWebhookの更新を別インスタンスのSSE接続に伝える必要がある → DBポーリング(5秒間隔)で補完
- ハートビート — SSE接続がプロキシに切られないよう定期的にpingを送る
- 接続数制限・バックプレッシャー — スロークライアント対策
- Stale mapping recovery — インメモリとDBの不整合を検知して復旧するロジック
バックエンドだけで約500行の追加コード。フロント側もEventSourceの接続管理・再接続・終端状態での切断処理が必要になった。
マルチインスタンス同期にはRedis Pub/Subが定番だが、MVPでRedisを追加したくなかったためDBポーリングで妥協した。SSEを選んだことで、さらに妥協が連鎖した例。
判断ミス2: ユーザーの行動パターンを見ていなかった
ここまで作ってから、冷静に考え直した。
ユーザーの行動パターン:
- ジョブを予約する
- 別の作業をする(数十分〜数時間)
- しばらくしてからプロダクトを開く
- 結果ができていれば見る
ユーザーは処理中にプロダクトの画面を見ていない。
気にしているのは「予約したかどうか」だけだ。予約が済めば安心して別の作業に移る。処理が終わった後、思い出した時にプロダクトを開いて結果が表示されていれば、それで目的は達成される。
つまり、リアルタイムにステータスを見せる相手がそもそも画面にいない。SSEで実装したリアルタイム通知は、誰にも届いていなかった。
判断ミス3: MVPに不要な複雑さを持ち込んだ
これはMVPフェーズの機能だった。この段階で検証すべきだったのは:
- ユーザーが「この機能を使いたいと思うか」
- 処理結果の品質は十分か
「ステータス遷移がリアルタイムに見えるか」はMVPの検証項目ではない。SSEを実装する工数で、他の機能の検証を1つ多く回せたはずだ。
Webhookベースならこうだった
バックエンド: WebhookでDBを更新するだけ。インメモリストアもSSEも不要。
フロント: SWRの revalidateOnFocus: true で、タブを切り替えた時に自動で最新データを取得。ユーザーが画面を開いた時点で結果が反映されている。
何を間違えたのか ― 振り返り
| SSE実装(実際) | Webhook + リロード(理想) | |
|---|---|---|
| バックエンドの追加コード | ~500行 | 0行 |
| インメモリストア | 必要 | 不要 |
| マルチインスタンス同期 | DBポーリングで補完 | 不要 |
| フロントの追加コード | EventSource + 再接続処理 | SWR(既存) |
| 運用上の考慮点 | 接続数監視、メモリリーク | なし |
根本の原因は、技術の問題ではなくUX設計の問題だった:
- ユーザーの行動パターンを考慮しなかった — 処理中にアプリを見ないという当たり前の事実を見落とした
- 「リアルタイム = 良いUX」という思い込み — リアルタイムが価値を生むのは、ユーザーが画面を見ている時だけ
- MVPで検証すべきことを見失った — ステータスの見せ方ではなく、機能そのものの価値を検証すべきだった
- 手段から入ってしまった — 「SSEを使ってみたい」が無意識にあった可能性
各パターンの詳細
ここからは、3パターンそれぞれの特徴を整理する。
ポーリング(Polling)
クライアントが一定間隔でサーバーに問い合わせるパターン。
// SWR によるポーリング — これだけで済むケースは多い
const { data } = useSWR("/api/jobs", fetcher, {
refreshInterval: 30000,
revalidateOnFocus: true,
});
メリット: 実装最小。ステートレス。既存ライブラリでそのまま使える。
デメリット: リアルタイム性は間隔依存。更新がなくてもリクエストが飛ぶ。
向いているケース: 更新頻度が低い(分〜時間単位)。リロード時に反映されればOK。
Long Pollingとの違い: 通常のポーリングはレスポンスを即返すが、Long Pollingはサーバーが更新があるまでレスポンスを保留する。リアルタイム性は上がるが、実装コストも上がる。
Webhook
外部サービスがイベント発生時に、事前に登録したURLへHTTP POSTで通知するパターン。
// Webhook受信の最小構成
func HandleWebhook(c *gin.Context) {
// 署名検証 → イベント処理 → 200 OK
if !verifySignature(c) { return }
event := parseEvent(c)
processEvent(event)
c.Status(http.StatusOK)
}
実装時に注意すべきポイントは3つ:
- 署名検証: Webhook送信元が正当かをHMAC-SHA256等で検証する
- 冪等性: 同じイベントが再送されても安全に処理できるようにする
- リトライ: 送信元は失敗時にリトライするので、一時的なエラーでもすぐ200を返さない設計が重要
メリット: リアルタイム性が高い。サーバー間通信に最適。接続管理不要。
デメリット: エンドポイントの公開が必要。署名検証・冪等性の実装が必要。
向いているケース: 外部サービスからの通知。サーバー間のイベント連携。
SSE(Server-Sent Events)
サーバーからクライアントへ、HTTP接続を維持したまま継続的にデータをPushするパターン。
// SSEクライアント — ブラウザ標準APIで使える
const es = new EventSource("/api/stream");
es.onmessage = (e) => updateUI(JSON.parse(e.data));
SSEは悪い技術ではない。 向いているユースケースでは非常に強力だ:
- AIの応答ストリーミング — ChatGPTのような逐次表示。ユーザーが画面を見ている
- リアルタイム通知 — Slack風の通知バッジ。ユーザーがアプリを開いている
- ライブダッシュボード — 株価、アクセス数。常時表示が前提
共通点は、ユーザーが画面を見ている前提のユースケースだということ。
WebSocketとの違い: SSEは単方向(Server → Client)、WebSocketは双方向。「サーバーから流すだけ」ならSSEの方がシンプル。チャットや共同編集のようにクライアントからもリアルタイムに送る場合はWebSocket。
HTTP/1.1ではブラウザが同一ドメインに張れるSSE接続は最大6本。タブを複数開くと枠を使い切る可能性がある。HTTP/2なら100本がデフォルトだが、インフラ側の接続管理コストが残る。
選定フローチャート
技術選定のフローへ入る前にユーザーの行動パターンを考える。ユーザーが画面を見ていないなら、どんなに高度なリアルタイム通信を実装しても意味がない。
比較表
| ポーリング | Webhook | SSE | |
|---|---|---|---|
| 通信方向 | Client → Server | Server → Server | Server → Client |
| リアルタイム性 | △(間隔依存) | ◎(イベント駆動) | ◎(即座に配信) |
| 実装コスト | ◎ 低い | ○ 中程度 | △ 高い |
| サーバー負荷 | △(定期リクエスト) | ◎(イベント時のみ) | △(接続維持) |
| スケーラビリティ | ○ | ◎ | △(接続管理が課題) |
| フロント実装 | fetch / SWR | — | EventSource |
| 主なユースケース | ジョブ状態確認 | 外部サービス連携 | 通知、ストリーミング |
まとめ
- 技術選定の前にUXを考える — 「ユーザーはその画面を見ているのか?」が最初の問い。見ていないならリアルタイム通知は不要
- シンプルな選択肢から検討する — ポーリング → Webhook → SSE → WebSocket の順に検討し、必要な時だけ複雑な手段を選ぶ
- 「リアルタイム = 良いUX」ではない — リアルタイムが価値を生むのはユーザーが画面を見ている時だけ。MVPではなおさら、まず機能の価値を検証すべき
