はじめに
「リアルタイム通信をやりたいけど、WebSocketはハードルが高すぎる...」と思ったことありませんか?そんな方々の間で注目されてるのが、Server Sent Events(以下SSE) です。
SSEは「サーバーからクライアントへの一方向通信」に特化した技術で、WebSocketよりもずっとシンプルで使いやすい場面が多いです。リアルタイム通知やライブデータフィード、進捗表示のような「サーバーから情報を送りたい」ケースでは、むしろSSEの方が適してるかもしれません。
この記事では、SSEの基本から実装方法、実際の運用で注意すべき点まで、皆さんが今日から使えるレベルまで詳しく解説していきます。
SSEとは?
シンプルな仕組み
Server Sent Events(SSE)は、サーバーがクライアントに対してリアルタイムでデータを送信するための仕組みです。特徴は以下の通り。
- HTTPプロトコル上で動作:特別なプロトコルは不要
- 一方向通信:サーバー→クライアントのみ
- 自動再接続機能:ネットワークが切れても勝手に再接続してくれる
- テキストベース:シンプルなテキスト形式でデータを送信
どういう仕組み?
SSEの動作流れは以下のような感じです。
- クライアントが普通のHTTPリクエストを送信
- サーバーが
Content-Type: text/event-streamでレスポンスして接続を維持 - サーバーが好きなタイミングでデータを送信
- クライアントは JavaScript の
EventSourceAPI でイベントを受け取る
// クライアント側(超シンプル!)
const sse = new EventSource('/stream');
sse.onmessage = (event) => {
console.log('データが届いたよ:', event.data);
};
データフォーマット
SSEのデータは、とてもシンプルなテキスト形式です。
// 基本的なメッセージ
data: Hello, World!
// 複数行のデータ
data: first line
data: second line
// IDとイベント名を指定
id: msg-123
event: update
data: {"progress": 50}
// 再接続間隔の指定
retry: 5000
data: some important data
主要なフィールド:
-
data:- 送信するメッセージの本体 -
id:- メッセージの一意ID(再接続時に使用) -
event:- カスタムイベント名(デフォルトは 'message') -
retry:- 再接続までの待機時間(ミリ秒)
WebSocketとの違いと使い分け
「SSEとWebSocket、どっちを使えばいいの?」と悩みますが、用途によって明確に使い分けできます。
比較表
| 特徴 | Server Sent Events (SSE) | WebSocket |
|---|---|---|
| 通信方向 | サーバー → クライアント(一方向) | 双方向 |
| プロトコル | HTTP/HTTPS | ws/wss(HTTPからアップグレード) |
| 再接続 | 自動(ブラウザが処理) | 手動(JavaScriptで実装が必要) |
| データ形式 | テキストのみ | テキスト・バイナリ |
| 実装の容易さ | 簡単 | やや複雑 |
| プロキシ/FW親和性 | 高い(普通のHTTP) | 設定が必要な場合あり |
| 学習コスト | 低い | 中〜高 |
使い分けの指針
SSEを選ぶ場面:
- ニュースフィードの更新
- 株価・仮想通貨の価格表示
- スポーツの試合速報
- ファイルアップロードの進捗表示
- サーバー側処理の進捗通知
- SNSの通知(いいね、コメントなど)
- システム監視ダッシュボード
WebSocketを選ぶ場面:
- オンラインゲーム
- リアルタイム協調編集(Google Docsのような)
- 高頻度なチャットアプリ
- リアルタイム描画アプリ
- 双方向通信が頻繁に必要な場合
一方向通信で十分なケースは意外と多いです「なんとなくWebSocket使ってたけど、SSEで十分だった」ということもよくありました。
実際の活用事例
1. リアルタイム通知システム
SNSアプリでの「いいね」や「コメント」通知です。
// サーバー側でイベント発生時
res.write(`event: notification\n`);
res.write(`data: {"type": "like", "user": "Alice", "post": 123}\n\n`);
// フロントエンド側
sse.addEventListener('notification', (event) => {
const data = JSON.parse(event.data);
showNotification(`${data.user}さんがあなたの投稿にいいねしました!`);
});
2. ライブデータフィード
株価や仮想通貨の価格をリアルタイム表示します。
// 価格更新を定期送信
setInterval(() => {
const price = getCurrentBitcoinPrice();
res.write(`event: price-update\n`);
res.write(`data: {"symbol": "BTC", "price": ${price}}\n\n`);
}, 1000);
3. 進捗表示
ファイルアップロードや動画エンコードの進捗を表示します。
// バックエンドでの長時間処理
async function processVideo(videoId) {
for (let progress = 0; progress <= 100; progress += 10) {
// 処理実行...
// 進捗を送信
res.write(`event: progress\n`);
res.write(`data: {"videoId": "${videoId}", "progress": ${progress}}\n\n`);
await sleep(1000);
}
}
4. シンプルなライブチャット
「SSEでメッセージ受信、POST APIでメッセージ送信」という構成も可能です。
// チャットメッセージの受信(SSE)
sse.addEventListener('chat', (event) => {
const message = JSON.parse(event.data);
addMessageToChat(message.user, message.text);
});
// メッセージ送信(通常のHTTP POST)
async function sendMessage(text) {
await fetch('/api/chat/send', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text })
});
}
この構成なら、WebSocketよりもシンプルで、認証やロードバランシングも楽になります。
Honoで実装してみる
streamSSEヘルパーがあるHonoを使って、実際にSSEサーバーを作ってみます。
基本的なSSEサーバー
import { Hono } from 'hono'
import { streamSSE } from 'hono/streaming'
import { serveStatic } from 'hono/cloudflare-workers' // または使用する環境に合わせて
const app = new Hono()
// 静的ファイル配信(フロントエンド用)
app.use('/static/*', serveStatic({ root: './public' }))
// SSEエンドポイント
app.get('/stream', (c) => {
return streamSSE(c, async (stream) => {
console.log('クライアントが接続しました');
// 自動的にヘッダーが設定される:
// Content-Type: text/event-stream
// Cache-Control: no-cache
// Connection: keep-alive
// ウェルカムメッセージ
await stream.writeSSE({
data: JSON.stringify({ message: "接続しました!" })
});
let id = 0;
// 定期的にデータを送信
while (true) {
try {
const data = {
timestamp: new Date().toISOString(),
randomValue: Math.floor(Math.random() * 100)
};
await stream.writeSSE({
id: String(++id),
event: 'update',
data: JSON.stringify(data)
});
// 2秒待機
await stream.sleep(2000);
} catch (error) {
console.log('クライアントが切断しました');
break;
}
}
});
});
export default app
より具体的な例:通知システム
import { Hono } from 'hono'
import { streamSSE } from 'hono/streaming'
const app = new Hono()
// 接続中のクライアント管理
interface StreamClient {
stream: any;
userId: string;
}
const clients = new Map<string, StreamClient>();
app.get('/notifications', (c) => {
const userId = c.req.query('userId') || Math.random().toString(36);
return streamSSE(c, async (stream) => {
console.log(`クライアント接続: ${userId}`);
// クライアント情報を保存
clients.set(userId, { stream, userId });
// 再接続対応:Last-Event-ID をチェック
const lastEventId = c.req.header('Last-Event-ID');
if (lastEventId) {
console.log(`再接続: Last-Event-ID = ${lastEventId}`);
// 必要に応じて未送信のメッセージを再送
}
// 接続維持用のダミーループ
stream.onAbort(() => {
console.log(`クライアント切断: ${userId}`);
clients.delete(userId);
});
// 無限ループで接続を維持
while (true) {
await stream.sleep(30000); // 30秒ごとにハートビート
await stream.writeSSE({
data: ': heartbeat'
});
}
});
});
// 特定のユーザーに通知を送信する関数
function sendNotification(userId: string, notification: any) {
const client = clients.get(userId);
if (client) {
const eventId = Date.now().toString();
client.stream.writeSSE({
id: eventId,
event: 'notification',
data: JSON.stringify(notification)
});
}
}
// 使用例:POST APIで通知をトリガー
app.post('/api/notify', async (c) => {
const { userId, message, type } = await c.req.json();
sendNotification(userId, {
type: type || 'info',
message,
timestamp: new Date().toISOString()
});
return c.json({ success: true });
});
export default app
重要な実装ポイント(Hono版)
-
streamSSEヘルパーを使用:必要なヘッダー(
Content-Type,Cache-Control,Connection)は自動設定 -
切断検知:
stream.onAbort()でクリーンアップ処理を簡潔に記述 - 型安全性:TypeScriptの恩恵で、開発時にエラーを早期発見
-
エラーハンドリング:
try-catchとstream.sleep()の組み合わせで安全なループ処理 - エッジ対応:Cloudflare WorkersやCloud Run等のエッジ環境でも動作
フロントエンド側:EventSource APIの使い方
フロントエンド側の実装は以下のとおりです。
基本的な使い方
// 接続開始
const sse = new EventSource('/stream');
// デフォルトのメッセージイベント
sse.onmessage = (event) => {
console.log('メッセージ受信:', event.data);
const data = JSON.parse(event.data);
// UIを更新
updateUI(data);
};
// 接続成功
sse.onopen = () => {
console.log('SSE接続が確立されました');
showStatus('接続中', 'green');
};
// エラー処理
sse.onerror = (error) => {
console.error('SSE接続エラー:', error);
showStatus('接続エラー', 'red');
// EventSourceは自動で再接続を試みます
};
// 接続を明示的に閉じる
function closeSSE() {
sse.close();
showStatus('切断済み', 'gray');
}
カスタムイベントの処理
const sse = new EventSource('/notifications');
// カスタムイベントをそれぞれ処理
sse.addEventListener('notification', (event) => {
const notification = JSON.parse(event.data);
showNotification(notification.message, notification.type);
});
sse.addEventListener('update', (event) => {
const data = JSON.parse(event.data);
updateDashboard(data);
});
sse.addEventListener('progress', (event) => {
const progress = JSON.parse(event.data);
updateProgressBar(progress.value);
});
ラッパークラスの例
class SSEClient {
constructor(url, options = {}) {
this.url = url;
this.options = options;
this.eventSource = null;
this.listeners = new Map();
this.reconnectAttempts = 0;
this.maxReconnectAttempts = options.maxReconnectAttempts || 5;
}
connect() {
this.eventSource = new EventSource(this.url);
this.eventSource.onopen = () => {
console.log('SSE connected');
this.reconnectAttempts = 0;
this.emit('connect');
};
this.eventSource.onmessage = (event) => {
this.emit('message', JSON.parse(event.data));
};
this.eventSource.onerror = (error) => {
console.error('SSE error:', error);
this.emit('error', error);
// 最大試行回数を超えた場合は諦める
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
console.log('最大再接続試行回数に達しました');
this.close();
this.emit('max_reconnect_exceeded');
} else {
this.reconnectAttempts++;
}
};
return this;
}
on(event, callback) {
if (!this.listeners.has(event)) {
this.listeners.set(event, []);
}
this.listeners.get(event).push(callback);
// カスタムイベントの場合はEventSourceにもリスナーを追加
if (event !== 'connect' && event !== 'error' && event !== 'message') {
this.eventSource?.addEventListener(event, (e) => {
this.emit(event, JSON.parse(e.data));
});
}
return this;
}
emit(event, data) {
const callbacks = this.listeners.get(event) || [];
callbacks.forEach(callback => callback(data));
}
close() {
this.eventSource?.close();
this.eventSource = null;
}
}
// 使用例
const client = new SSEClient('/stream', { maxReconnectAttempts: 10 })
.on('connect', () => console.log('接続しました!'))
.on('message', (data) => console.log('データ:', data))
.on('notification', (notification) => showAlert(notification.message))
.on('error', (error) => console.error('エラー:', error))
.connect();
ブラウザサポートと制限事項
ブラウザサポート状況
SSEはほとんどのモダンブラウザで使用できます。
- ✅ Chrome/Edge: 完全サポート
- ✅ Firefox: 完全サポート
- ✅ Safari: 完全サポート
- ❌ Internet Explorer: サポートなし
上記の通りIE以外のモダンブラウザなら問題なく使えます。
詳細は Can I Use SSE で確認してください。
HTTP/1.1での制限事項
同時接続数の制限
HTTP/1.1環境では、同一ドメインへの同時接続数に制限があります(通常6つ)。SSEは1つの接続を長時間占有するので、他のリソース(画像、CSS、JSなど)の取得がブロックされる可能性がありました。
// HTTP/1.1環境での問題例
const sse1 = new EventSource('/stream1');
const sse2 = new EventSource('/stream2');
const sse3 = new EventSource('/stream3');
// ... 6つ以上のSSE接続や他のHTTPリクエストで問題が発生する可能性
HTTP/2による解決
多重化による根本的解決
HTTP/2の最も重要な機能である「多重化」により、この問題は解決されます。
// HTTP/2環境では問題なし!
const sse1 = new EventSource('/notifications');
const sse2 = new EventSource('/live-data');
const sse3 = new EventSource('/progress');
// 同時に画像やAPIリクエストも問題なく処理可能
HTTP/2では1つのTCP接続上で複数の独立したストリームを並行処理できるため、SSEが他のリソース取得を妨げることがありません。
その他の制限事項
- テキストのみ: バイナリデータは直接送信できない(Base64エンコードは可能)
- 一方向通信: クライアントからサーバーへのリアルタイム送信は別途HTTP APIが必要
- プロキシバッファリング: 一部のプロキシサーバーでレスポンスがバッファリングされる可能性(後述)
なぜ今SSEなのか
HTTP/2・HTTP/3の普及
現在、主要なWebサイトの多くがHTTP/2を採用しており、HTTP/3の普及も進んでいます。これにより以下のような恩恵が得られます。
- SSEのパフォーマンス大幅向上
- 接続数制限問題の解消
- レイテンシの改善
サーバーレスアーキテクチャでの利用
Vercelをはじめとする最新のサーバーレスプラットフォームでもSSEが利用可能になっています。
// Vercel Edge Functions でのSSE
export const runtime = 'edge';
export async function GET() {
const encoder = new TextEncoder();
const stream = new ReadableStream({
start(controller) {
const interval = setInterval(() => {
const data = `data: ${JSON.stringify({ time: Date.now() })}\n\n`;
controller.enqueue(encoder.encode(data));
}, 1000);
// 60秒後に終了(Edge Functionsの制限)
setTimeout(() => {
clearInterval(interval);
controller.close();
}, 60000);
},
});
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
},
});
}
モダンフレームワークでの簡単実装
Next.js 13+ App Router
// app/api/stream/route.js
export async function GET() {
const encoder = new TextEncoder();
const stream = new ReadableStream({
start(controller) {
// ストリーミング処理
},
});
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
},
});
}
SvelteKit
// src/routes/api/stream/+server.js
export async function GET({ setHeaders }) {
setHeaders({
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache'
});
return new Response(
new ReadableStream({
start(controller) {
// ストリーミング処理
}
})
);
}
なぜ今SSEが注目されるのか
- シンプルさ: WebSocketより実装が簡単
- インフラ親和性: 既存のHTTPインフラとの相性が良い
- パフォーマンス向上: HTTP/2・HTTP/3による改善
- フレームワークサポート: 主要フレームワークでの標準サポート
- 運用のしやすさ: 認証・ロードバランシング・監視が従来通り
実際の運用で気をつけること
実際にSSEを本番環境で使う時に遭遇しがちな問題と対策を見ていきます。
1. リバースプロキシのバッファリング問題
問題: Nginxなどがレスポンスをバッファリングしてリアルタイム性が失われる
対策:
# Nginx設定例
location /stream {
proxy_pass http://backend;
proxy_buffering off;
proxy_cache off;
proxy_set_header Connection '';
proxy_http_version 1.1;
}
または、アプリケーション側から:
res.setHeader('X-Accel-Buffering', 'no'); // Nginx用
2. 接続集中問題(Thundering Herd)
問題: サーバー再起動時に大量のクライアントが一斉に再接続
対策:
// サーバー側:再接続間隔を調整
res.write('retry: 10000\n'); // 10秒後に再接続
// クライアント側:指数バックオフ
class RobustSSEClient {
connect(attempt = 0) {
const delay = Math.min(1000 * Math.pow(2, attempt), 30000);
setTimeout(() => {
this.eventSource = new EventSource(this.url);
this.eventSource.onerror = () => {
this.connect(attempt + 1);
};
}, delay);
}
}
3. メモリリーク対策
問題: クライアント情報の蓄積によるメモリリーク
対策:
const clients = new Map();
// 定期的なクリーンアップ
setInterval(() => {
for (const [clientId, res] of clients.entries()) {
if (res.destroyed || res.closed) {
clients.delete(clientId);
}
}
}, 60000); // 1分ごとにクリーンアップ
// グレースフルシャットダウン(安全にシャットダウン)
process.on('SIGTERM', () => {
console.log('Graceful shutdown...');
// 全クライアントに終了通知
for (const [_, res] of clients.entries()) {
try {
res.write('event: shutdown\ndata: Server is shutting down\n\n');
res.end();
} catch (err) {
// 既に切断済みの場合は無視
}
}
server.close(() => {
process.exit(0);
});
});
4. 認証・認可の実装
問題: SSE接続の認証をどう実装するか
対策:
// JWT認証の例
app.get('/stream', authenticateToken, (req, res) => {
// 認証済みユーザーの情報
const userId = req.user.id;
// SSEセットアップ
setupSSE(res, userId);
});
function authenticateToken(req, res, next) {
const authHeader = req.headers['authorization'];
const token = authHeader && authHeader.split(' ')[1];
if (!token) {
return res.sendStatus(401);
}
jwt.verify(token, process.env.ACCESS_TOKEN_SECRET, (err, user) => {
if (err) return res.sendStatus(403);
req.user = user;
next();
});
}
// クライアント側
const sse = new EventSource('/stream', {
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`
}
});
まとめ
こんなときはSSE
- サーバーからの情報プッシュが主な用途
- リアルタイム通知システム
- ライブデータフィード(株価、スポーツ速報など)
- 進捗表示やステータス更新
- システム監視ダッシュボード
- シンプルなライブチャット
- 実装・運用のシンプルさを重視
こんなときはWebSocket
- 双方向通信が頻繁に必要
- 低レイテンシが重要(ゲームなど)
- バイナリデータのやり取りが多い
- 複雑なプロトコルが必要
最後に
SSEは「シンプルで実用的なリアルタイム通信」を実現する素晴らしい技術です。WebSocketほど複雑ではなく、普通のHTTPリクエストの延長として扱えるので、既存のインフラや知識をそのまま活用できます。
特に、HTTP/2・HTTP/3の普及により、従来の制限事項も解消され、現代のWebアプリケーションでの実用性はさらに高まっています。
「リアルタイム通信やりたいけど、WebSocketはハードルが高い...」と感じているエンジニアの皆さん、ぜひSSEを試してみてください!