Webアプリのリアルタイム通知・同期を実現する技術についてまとめてみた
はじめに
GitHubでPRを開いていると、誰かがコメントした瞬間にページが更新される。Discordでチャットしていると、相手のメッセージがリアルタイムで流れてくる。Notionで共同編集していると、他の人のカーソルが見える。
こういった「リロードしなくても画面が更新される」体験、当たり前のように使っているけど、どうやって実現しているんだろう?と気になったのでまとめてみました。
リアルタイム通信の選択肢
まず、サーバーからクライアントにデータを届ける方法として、主に以下の選択肢があります。
| 方式 | 方向 | 接続 | リアルタイム性 | 実装コスト |
|---|---|---|---|---|
| Polling | クライアント→サーバー | 都度 | 低(間隔依存) | 低 |
| Long Polling | クライアント→サーバー | 保持※1 | 中 | 中 |
| Server-Sent Events (SSE) | サーバー→クライアント※2 | 持続 | 高 | 低〜中 |
| WebSocket | 双方向 | 持続 | 高 | 中〜高 |
※1 正確には「レスポンスが返る、またはタイムアウトするまでサーバーがリクエストを保留する」という挙動
※2 クライアントからサーバーへは通常のHTTPリクエスト(POST等)を併用することで擬似的な双方向通信として使われることが多い
それぞれ見ていきます。
Polling(ポーリング)
一番シンプルな方法。定期的にAPIを叩いて「なんか変わった?」と聞きに行く。
// 5秒ごとにAPIを叩く
setInterval(async () => {
const response = await fetch('/api/notifications');
const data = await response.json();
if (data.hasUpdates) {
updateUI(data); // UIを更新する関数(実装は省略)
}
}, 5000);
メリット
- 実装が簡単
- どんな環境でも動く
- サーバー側も普通のAPIを用意するだけ
デメリット
- リアルタイム性が低い(間隔を短くすると負荷が上がる)
- 更新がなくても無駄なリクエストが発生する
- クライアント数が増えるとサーバー負荷が厳しい
天気アプリやダッシュボードなど、更新頻度が低いものには今でも十分使える方法だと思います。
タブが非アクティブな時は止める
setIntervalはタブがバックグラウンドでも動き続けるので、無駄なリクエストが発生します。Page Visibility APIを使って、アクティブな時だけPollingするのが実用的です。
let intervalId;
function startPolling() {
intervalId = setInterval(async () => {
const response = await fetch('/api/notifications');
const data = await response.json();
updateUI(data); // UIを更新する関数(実装は省略)
}, 5000);
}
function stopPolling() {
clearInterval(intervalId);
}
document.addEventListener('visibilitychange', () => {
if (document.hidden) {
stopPolling();
} else {
startPolling();
// タブに戻ってきた時は即座に取得
fetch('/api/notifications').then(r => r.json()).then(updateUI);
}
});
startPolling();
これでユーザーが見ていない間は通信を止めつつ、タブに戻った瞬間に最新データを取得できます。
Long Polling(ロングポーリング)
Pollingの改良版。サーバー側で「更新があるまでレスポンスを返さない」ようにする。
async function longPoll() {
try {
const response = await fetch('/api/notifications/subscribe', {
timeout: 30000 // タイムアウトを長めに
});
const data = await response.json();
updateUI(data); // UIを更新する関数(実装は省略)
} catch (e) {
// タイムアウトやエラー時
}
// 即座に次のリクエスト
longPoll();
}
longPoll();
サーバー側(擬似コード):
def subscribe(request):
# 更新があるまで待機
while not has_updates(request.user):
sleep(0.1)
if timeout_exceeded():
return empty_response()
return get_updates(request.user)
メリット
- Pollingより即時性が高い
- HTTPの仕組みだけで実現できる
デメリット
- コネクションを張りっぱなしにするのでサーバーリソースを消費
- 複数のデータソースを監視したい場合、コネクションが増える
Cometという名前で呼ばれることもあります。WebSocket(2011年にRFC化)が普及する前の2000年代後半〜2010年代前半は、リアルタイムWebといえばこの方式が主流でした。Gmailのチャット機能などもこれで実装されていたようです。
Server-Sent Events(SSE)
HTTP上でサーバーからクライアントへの一方向ストリームを実現する仕組み。
SSEは持続的な接続を張り続ける性質上、HTTP/1.1だと同一ドメインへの接続数制限(ブラウザごとに6本程度)を消費してしまう問題があります。HTTP/2では1つのTCP接続内で複数のストリームを多重化できるため、この制限を気にせず使えます。
const eventSource = new EventSource('/api/notifications/stream');
eventSource.onmessage = (event) => {
const data = JSON.parse(event.data);
updateUI(data); // UIを更新する関数(実装は省略)
};
eventSource.onerror = (error) => {
console.error('SSE error:', error);
// 自動的に再接続される
};
サーバー側(Node.js):
app.get('/api/notifications/stream', (req, res) => {
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
// 再接続時、クライアントは最後に受信したイベントIDを送ってくる
const lastEventId = req.headers['last-event-id'];
if (lastEventId) {
// 切断中に発生したイベントを再送(実装は省略)
// 例: DBやメモリからlastEventId以降のイベントを取得
const missedEvents = getEventsSince(lastEventId);
missedEvents.forEach(event => sendEvent(res, event));
}
const sendEvent = (data, id) => {
res.write(`id: ${id}\n`);
res.write(`data: ${JSON.stringify(data)}\n\n`);
};
// 何かしらのイベントをsubscribe(実装は省略)
// 例: Redis Pub/SubやEventEmitterでイベントを受け取る
const unsubscribe = eventEmitter.on('notification', (data, id) => {
sendEvent(data, id);
});
req.on('close', () => {
unsubscribe();
});
});
自動再接続とLast-Event-ID
SSEにはブラウザレベルで自動再接続機能が組み込まれています。接続が切れると、ブラウザは自動的に再接続を試みます。
このとき、サーバーが各イベントに id フィールドを付けて送信していれば、再接続時にブラウザは Last-Event-ID ヘッダーで「最後に受信したイベントのID」をサーバーに伝えます。サーバーはこれを使って、切断中に発生したイベントを再送できます。WebSocketでは自前で実装する必要がある機能が、SSEにはプロトコルレベルで備わっているのは強みです。
SSEのイベントフォーマット
SSEはHTTPレスポンスボディの中で、text/event-stream という特定のテキストフォーマットでデータを送ります。HTTPヘッダーではなく、ボディ内のプレーンテキストです。
id: 1001
event: notification
data: {"type": "message", "content": "Hello"}
id: 1002
event: notification
data: {"type": "message", "content": "World"}
各フィールドの意味:
-
id:- イベントの識別子。再接続時にLast-Event-IDとして送り返される -
event:- イベントタイプ。省略するとmessageとして扱われる -
data:- 実際のデータ。複数行も可能
各イベントは空行(\n\n)で区切ります。サーバー側のコードで res.write(id: ${id}\ndata: ${JSON.stringify(data)}\n\n) としているのは、このフォーマットに従っています。
メリット
- 実装がシンプル(WebSocketより簡単)
- ブラウザが自動再接続してくれる
- HTTPの仕組みの上で動くので、既存のインフラ(プロキシ、ロードバランサー)と相性が良い
デメリット
- サーバー→クライアントの一方向のみ(クライアントからの送信は通常のHTTP POSTなどと組み合わせる)
- IE/Edgeの古いバージョンは非対応(今はほぼ問題ない)
- HTTP/1.1だと同一ドメインへの接続数制限に引っかかることがある
接続数制限の詳細
冒頭で触れたHTTP/1.1の接続数制限について、もう少し具体的に見てみます。
HTTP/1.1の問題
HTTP/1.1では、1つのTCP接続で同時に処理できるリクエストは1つだけです(Head-of-Line Blocking)。そのため、ブラウザは並列でリクエストを送るために複数のTCP接続を張りますが、同一ドメインに対して6つまでという制限があります。SSEで1つ使うと残り5つ。複数タブで同じサイトを開くと、すぐに枯渇して通信がブロックされます。
HTTP/2の多重化(Multiplexing)
HTTP/2では、1つのTCP接続の中に複数の「ストリーム」を作成できます。各ストリームは独立した論理的な通信チャネルで、リクエストとレスポンスを並列に処理できます。
HTTP/2の仕様上、1つの接続で同時に開けるストリーム数はサーバーとクライアントの設定次第ですが、デフォルトで100〜256程度、最大で数千まで設定可能です。実用上、SSEを数十本張っても問題になることはほぼありません。
HTTP/2以降では1つのTCP接続内で多重化されるため、HTTP/1.1の6接続制限を回避できます。SSEを本番で使うならHTTP/2の導入がほぼ必須です。
通知のプッシュやフィードの更新など、サーバーからの一方向配信で十分なケースではSSEが手軽で良さそうです。
WebSocket
TCPコネクション上で双方向通信を実現するプロトコル。最初にHTTPでハンドシェイクして、その後は独自プロトコルで通信する。
const ws = new WebSocket('wss://example.com/ws');
ws.onopen = () => {
console.log('接続完了');
// サーバーにメッセージを送れる
ws.send(JSON.stringify({ type: 'subscribe', channel: 'notifications' }));
};
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
handleMessage(data); // 受信したメッセージを処理する関数(実装は省略)
};
ws.onclose = () => {
console.log('切断された');
// 再接続処理
};
サーバー側(Node.js + ws):
const WebSocket = require('ws');
const wss = new WebSocket.Server({ port: 8080 });
wss.on('connection', (ws) => {
ws.on('message', (message) => {
const data = JSON.parse(message);
if (data.type === 'subscribe') {
// チャンネルに購読登録(実装は省略)
// 例: channels[data.channel].add(ws) のようにクライアントを管理
subscribeToChannel(ws, data.channel);
}
});
ws.on('close', () => {
// クリーンアップ
});
});
// 全クライアントにブロードキャスト
function broadcast(data) {
wss.clients.forEach((client) => {
if (client.readyState === WebSocket.OPEN) {
client.send(JSON.stringify(data));
}
});
}
メリット
- 双方向通信ができる
- オーバーヘッドが小さい(ヘッダーが数バイト)
- リアルタイム性が高い
デメリット
- 実装が複雑になりがち(再接続、heartbeat、状態管理など)
- ステートフルなのでスケールが難しい(クライアントは特定のサーバーと接続を維持するため、サーバーを増やしても単純にロードバランスできない。後述のPub/Sub構成が必要)
- 一部のプロキシやファイアウォールで問題が起きることがある(HTTP Upgradeを理解しない古いプロキシや、長時間接続を切断する企業ファイアウォールなど)
チャット、ゲーム、共同編集など、双方向のリアルタイム通信が必要なケースではWebSocket一択になると思います。
実際のサービスはどうしているか
調べてみた範囲で、いくつかのサービスの実装をまとめてみます。
GitHub
GitHubは alive.github.com というWebSocketエンドポイントを使っているようです。DevToolsのNetworkタブで確認できます。
リバースエンジニアリングした人のリポジトリによると、以下のようなイベントをsubscribeできるとのこと:
-
notification-changed- 通知の更新 -
workflow_run- GitHub Actionsの実行状態 -
check_suites- CIのステータス
ページごとにWebSocket接続を共有して、必要なイベントだけsubscribeする設計のようです。
Discord
Discordはリアルタイム通信のお手本みたいな存在ですね。
技術スタック
- Gateway(WebSocket接続を受ける層): Elixir
- API: Python
- 音声/ビデオ: C++ + WebRTC
なぜElixirなのか
Elixirは Erlang VM(BEAM)上で動作する関数型プログラミング言語です。Erlangはもともと1980年代にエリクソン社が電話交換機のために開発した言語で、「99.9999999%(ナインナイン)の可用性」を実現するために設計されました。
Elixir/Erlang VMの特徴:
- 軽量プロセス: OSスレッドではなくVM内の軽量プロセスを使用。1プロセスあたり数KBのメモリで、数百万プロセスを同時に起動できる
- 耐障害性: 「Let it crash」哲学。プロセスがクラッシュしても他に影響せず、Supervisorが自動で再起動する
- メッセージパッシング: プロセス間は共有メモリではなくメッセージで通信するため、競合状態が起きにくい
この特性がWebSocketの大量同時接続と相性が良く、「1ユーザー = 1プロセス」というシンプルなモデルで設計できます。
アーキテクチャのポイント
-
Guild(サーバー)ごとに1つのElixirプロセス
- そのGuildで起きるすべてのイベントの中央ルーティングポイント
- 接続ユーザーごとに別のプロセス
- 更新があるとファンアウトして各ユーザーのセッションプロセスに配信
-
シャーディング
- Gatewayは数千のシャードに分割
- 各シャードは約5,000ユーザーを担当(ユーザーIDをハッシュして振り分け)
- シャードがクラッシュしても影響は一部のユーザーのみに限定され、他のシャードは稼働し続ける
-
パッシブ接続
- ユーザーが見ていないGuildへの接続は「パッシブ」にして更新をスキップ
- これで90%の通信を削減したらしい
-
ステートレスなGateway
- WebSocket接続自体はステートフル(特定のGatewayサーバーと接続を維持)だが、セッション情報はRedisに外部化
- これにより、Gatewayがクラッシュしてもセッション情報は失われず、クライアントは別のGatewayに再接続するだけで復帰できる
- 「接続はステートフル、状態管理はステートレス」という設計でスケーラビリティを確保
Elixir(Erlang VM)の軽量プロセスと耐障害性がDiscordの大規模リアルタイム通信を支えているのが印象的です。2017年頃には「5人のエンジニアで数百万同時接続を捌いている」という伝説的なエピソードがありましたが、現在は同時接続3,000万人以上、登録ユーザー5億人以上という規模を、このアーキテクチャの進化形(Elixir + Rust)で支えています。
Rustへの一部移行
Elixirにも課題がありました。Erlang VMのGC(ガベージコレクション)は軽量プロセスごとに独立して動くため通常は高速ですが、大量のデータを扱うプロセスではGC実行時に数ミリ秒〜数十ミリ秒のレイテンシスパイクが発生することがあります。
DiscordはRead States(既読管理)などの一部コアシステムをRustに書き換えることで、GCによる停止が一切ない安定したレイテンシを実現しました。同様の理由で、以前Goで実装していたサービスもRustに移行しています(GoのGCも同様にスパイクの原因になっていた)。
ElixirとRustのハイブリッド構成で、それぞれの強みを活かしている形ですね。
Slack
SlackもWebSocketを使っています。RTM(Real Time Messaging)APIとして公開されていて、Botを作るときなどに使えます。
最近はSocket Modeという仕組みもあって、WebSocket経由でイベントを受け取れるようになっています。
Notion / Figma
共同編集系のサービスはWebSocketに加えて、データの整合性を保つための仕組みが必要になります。
- OT(Operational Transformation): Google Docsが使っている方式。操作を変換して競合を解決。サーバーが「真実」を知っている必要があり、中央集権的な処理に向いている
- CRDT(Conflict-free Replicated Data Type): Figmaが使っている方式。データ構造自体が競合しないように設計されており、サーバーなしのP2Pでも整合性が保てる。ローカルファーストなアプリに強い
これらは「通信プロトコル」ではなく、その上の「データ同期アルゴリズム」の話になってくるので、また別の機会に深掘りしたいところです。
実装時に考慮すべきこと
再接続の処理
WebSocket/SSEは接続が切れることがあるので、再接続の処理が必須です。
class ReconnectingWebSocket {
constructor(url) {
this.url = url;
this.reconnectInterval = 1000;
this.maxReconnectInterval = 30000;
this.connect();
}
connect() {
this.ws = new WebSocket(this.url);
this.ws.onopen = () => {
this.reconnectInterval = 1000; // リセット
};
this.ws.onclose = () => {
// Jitterを加えて再接続タイミングをばらけさせる
const jitter = Math.random() * 1000;
const delay = Math.min(
this.reconnectInterval + jitter,
this.maxReconnectInterval
);
setTimeout(() => {
this.reconnectInterval = Math.min(
this.reconnectInterval * 2,
this.maxReconnectInterval
);
this.connect();
}, delay);
};
}
}
Exponential backoffで再接続間隔を伸ばしていくのが定番です。固定間隔だとサーバー障害時に全クライアントが一斉に再接続を試みて負荷が集中(Thundering Herd問題)するため、間隔を指数的に伸ばすことで負荷を分散させます。
さらに、同じタイミングで切断されたクライアントが同じ計算式で「2秒後→4秒後→8秒後」と同時に再接続を試みる可能性があるため、上記のように Jitter(ランダムなゆらぎ) を加えるのがベストプラクティスです。
Heartbeat / Ping-Pong
接続が生きているか確認するために、定期的にpingを送る実装が一般的です。
setInterval(() => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'ping' }));
}
}, 30000);
サーバー側でも一定時間応答がないクライアントは切断する処理が必要です。
スケーリング
WebSocketはステートフルなので、複数サーバーでスケールする場合はPub/Subの仕組みが必要になります。
RedisのPub/SubやKafkaなどを使って、サーバー間でイベントを共有するパターンが多いです。
認証
WebSocketの接続時に認証する方法はいくつかあります。
なぜ特別な対応が必要か
ブラウザの new WebSocket() APIでは、カスタムHTTPヘッダー(Authorization: Bearer ... など)を付与できません。これはセキュリティ上の理由(CORSの制約やクレデンシャルの扱い)とAPIのシンプルさを優先した設計によるものです。fetch のように headers オプションを指定する方法が使えないため、別の手段が必要になります。
認証方法
- クエリパラメータにトークンを含める(簡単だけどログに残るリスク)
- 最初のメッセージで認証(WebSocket接続後に認証メッセージを送る)
- Cookieを使う(same-originなら自動で送られる)
// 方法1: クエリパラメータ
const ws = new WebSocket(`wss://example.com/ws?token=${token}`);
// 方法2: 最初のメッセージで認証
ws.onopen = () => {
ws.send(JSON.stringify({ type: 'auth', token }));
};
ライブラリ・サービス
自前で実装するのは大変なので、ライブラリやマネージドサービスを使うのも手です。
ライブラリ
- Socket.IO: WebSocketのラッパー。フォールバックや再接続を自動でやってくれる
- ws: Node.js用のシンプルなWebSocketライブラリ
- Primus: 複数のリアルタイムフレームワークを抽象化
マネージドサービス
- Pusher: WebSocketのマネージドサービス。Laravelとの統合が有名
- Ably: Pusherの競合
- Firebase Realtime Database / Firestore: リアルタイム同期をDBレベルで提供
Laravelを使っているなら、Laravel Echo + Pusherの組み合わせがかなり楽だと思います。
どれを選ぶか
個人的な判断基準としては:
- 更新頻度が低い、シンプルに済ませたい → Polling
- サーバーからの一方向プッシュで十分 → SSE
- 双方向通信が必要、チャットやゲーム → WebSocket
- インフラ管理したくない → Pusher等のマネージドサービス
もちろんケースバイケースなので、要件に応じて選ぶのが良いと思います。
おわりに
リアルタイム通信の技術について調べてみました。GitHubやDiscordの実装を見ると、単にWebSocketを使っているだけでなく、スケーリングや耐障害性のための工夫がたくさんあることがわかります。
特にDiscordのElixir/Erlang VMを活用したアーキテクチャは、リアルタイムシステムの設計として参考になりそうです。
Discussion