Next.js リアルタイムチャット:WebSocket と SSE の正しい使い方
Next.js で WebSocket をサポートさせようと、3 回目の挑戦。ページがエラーを吐く:「WebSocket is not supported in this environment」。ローカルではチャット機能が問題なく動いていたのに、Vercel にデプロイした瞬間にクラッシュした。
気づいた事実がある——Next.js のリアルタイム通信は、想像以上にシンプルではない。
本記事では、WebSocket を Next.js に無理やり押し込む方法は教えません(その道は試したが、行き止まりだった)。実際に踏んだ坑を共有します:Vercel が WebSocket に非対応な理由、SSE が救いになるか、Socket.io を App Router とどう共存させるか。Next.js のリアルタイム機能で悩んでいるなら、遠回りを減らせるはずです。
3 つのリアルタイム通信方式の比較
チャットルーム、協調編集、リアルタイム通知——これらの裏側では、サーバーがクライアントへ能動的にデータをプッシュする必要があります。HTTP のリクエスト・レスポンスモデルでは解決できないので、3 つの主流方式があります。
WebSocket:全二重通信の理想
WebSocket は最も理想的——接続を 1 回確立すれば、クライアントとサーバーがいつでも相互にメッセージを送れます。迷う余地なし、と思うかもしれません。
私もそう思っていました。デプロイの瞬間まで。
Vercel、Netlify などの Serverless プラットフォームは長時間接続に非対応です。Next.js アプリはクラウド関数上で動き、リクエストが終わると関数は回収される——WebSocket 接続を維持できるはずがありません。Pusher や Ably などのサードパーティ WebSocket サービスも試したが、月額料金で断念した。
とはいえ、WebSocket が使えないわけではありません。自前サーバーを借りてデプロイするか、別の Node.js バックエンドで WebSocket サービスを動かす——どちらも成立します。ただしコストが上がり、アーキテクチャも複雑になります。
SSE:一方向チャネルの救急策
Server-Sent Events——名前のとおり一方向で、サーバーからクライアントへのプッシュのみ。
少し物足りなく聞こえるかもしれません——送信は HTTP POST、受信は SSE。別の角度から見れば、多くのシーンにちょうど合います:チャットルームでは送信は能動的、受信は受動的。リアルタイム通知はサーバーからのプッシュだけで足ります。
最重要ポイント:SSE は Vercel で動く。
以前、シンプルな通知システムを SSE で作り、Vercel デプロイの大問題を解決しました。制限はあります(Vercel Edge Function の 25 秒タイムアウト)が、メッセージプッシュには十分でした。
Long Polling:古いが実用的
最も古い方式:クライアントがリクエストを送り、サーバーは新着があるまで待ってから返し、クライアントはすぐ次のリクエストを送る。
パフォーマンスは良くなく、トラフィックも無駄になります。ただしユーザーが数百人程度で、メッセージも頻繁でなければ、Long Polling は実に使いやすい——コードがシンプル、互換性問題なし、Serverless プラットフォームも拒否しない。
正直、Long Polling で凌いでいる小規模プロジェクトを数多く見てきました。UX も大きな問題はありません。「技術的負債」という言葉に怯えないで——問題を解決できれば良い方案です。
比較表:一目でわかる
| 特性 | WebSocket | SSE | Long Polling |
|---|---|---|---|
| 双方向通信 | ✅ | ❌(POST と併用) | ✅ |
| Vercel 対応 | ❌ | ✅(タイムアウト制限あり) | ✅ |
| ブラウザ互換 | モダンブラウザ | モダンブラウザ | 全部 |
| 接続オーバーヘッド | 低 | 低 | 高 |
| 実装の複雑さ | 中程度 | シンプル | 非常にシンプル |
| 向くシーン | チャット、ゲーム、協調編集 | 通知、リアルタイム更新 | 低頻度メッセージ、高互換性要件 |
気づいたかもしれません:Next.js + Vercel の組み合わせでは、SSE と Long Polling が主流。技術の後退ではなく、プラットフォーム制限の中で見つけた実用的な方案です。
Next.js 環境でのリアルタイム通信の選定
3 方式を知ることと、どれを選ぶかは別問題。ここでは、坑を踏んだ後にようやく理解した判断ポイントをまとめます。
デプロイプラットフォームが最初の分岐点
Vercel、Netlify、Cloudflare Pages など Serverless プラットフォームを使うなら、WebSocket はほぼ無理です。選択肢は 3 つ:
- SSE 方式:一方向プッシュ向け(通知、リアルタイム更新、チャットルームのメッセージ受信)
- Long Polling:低頻度の双方向通信向け
- 独立 WebSocket サービス:小さなサーバーを借りて WebSocket 専用で動かし、Next.js アプリは Serverless のまま
私自身のプロジェクトでは、本当に高頻度双方向通信(複数人協調編集など)でなければ SSE を選びます。デプロイがシンプルで、財布にも優しい。
自前サーバー(VPS、Docker デプロイ)なら WebSocket は自由に使えます——ただしロードバランシング、プロセス管理も自分で面倒を見る必要があります。
ユーザー数がアーキテクチャの複雑さを決める
同時オンラインは何人ですか?
- 100 人未満:Long Polling で十分。1 時間で書けるほどシンプル
- 100〜1000 人:SSE またはシンプルな WebSocket 方案
- 1000 人超:メッセージキュー(Redis Pub/Sub)、ロードバランシング、マルチインスタンスが必要
あるスタートアップチームの話——初版は Long Polling、ユーザーが 500 に達してから SSE に切り替え。良い流れです——前期はアイデアを素早く検証し、後期でパフォーマンスを最適化。
メッセージ頻度が方式選択に影響
通知システムで数分に 1 件程度なら Long Polling で十分。チャットルームで数十人が同時送信なら SSE か WebSocket が適切。
見落としがちな点:ブラウザは同一ドメインの HTTP 接続数に制限(通常 6 個)。Long Polling や SSE を使うと、複数タブを開くと詰まることがあります。WebSocket を使うか、単一タブ検知を実装する。
予算も重要な要素
サードパーティ WebSocket サービス(Pusher、Ably、PubNub)は確かに便利ですが、メッセージ量課金。500 人同時オンラインのチャットルームなら、WebSocket だけで月 $49〜99 かかる計算をしたことがあります。
自前デプロイの場合:
- Vercel + SSE:無料枠が大きく、小規模プロジェクトは無料
- VPS + WebSocket:$5/月〜(Vultr、DigitalOcean)
- Railway/Render:WebSocket 対応、$5〜10/月
判断フロー(参考)
Vercel にデプロイ?
├─ はい → ユーザー > 1000?
│ ├─ はい → SSE + Redis Pub/Sub
│ └─ いいえ → シンプル SSE または Long Polling
└─ いいえ(自前ホスト)→ 双方向高頻度通信が必要?
├─ はい → WebSocket + Socket.io
└─ いいえ → SSE
結局、技術選定に絶対の正解はありません。私の経験:まず最もシンプルな方案でリリースし、本当に限界が来たら最適化。最初から WebSocket クラスターを組んだプロジェクトの多くは、結局 100 ユーザーも集まらなかった。
Socket.io 統合の実践
WebSocket を使う決断をしたなら(自前サーバーデプロイなど)、Socket.io が最も成熟したライブラリです。Long Polling への自動フォールバック、切断再接続、ルーム管理が組み込まれています。
ただし Next.js への Socket.io 統合は、想像以上に坑が多い。App Router の Route Handler は res.socket をサポートせず、Custom Server が必要です。
ステップ 1:Custom Server の作成
Next.js のデフォルト起動方式は WebSocket 非対応。カスタムサーバーが必要です。プロジェクトルートに server.js を作成:
// server.js
const { createServer } = require('http');
const { parse } = require('url');
const next = require('next');
const { Server } = require('socket.io');
const dev = process.env.NODE_ENV !== 'production';
const hostname = 'localhost';
const port = 3000;
const app = next({ dev, hostname, port });
const handle = app.getRequestHandler();
app.prepare().then(() => {
const httpServer = createServer((req, res) => {
const parsedUrl = parse(req.url, true);
handle(req, res, parsedUrl);
});
// 初始化 Socket.io
const io = new Server(httpServer, {
cors: {
origin: dev ? 'http://localhost:3000' : 'https://yourdomain.com',
methods: ['GET', 'POST']
}
});
// 连接处理
io.on('connection', (socket) => {
console.log('用户连接:', socket.id);
// 加入房间
socket.on('join_room', (roomId) => {
socket.join(roomId);
console.log(`用户 ${socket.id} 加入房间 ${roomId}`);
});
// 接收消息
socket.on('send_message', (data) => {
// 发送给房间内所有人(包括自己)
io.to(data.room).emit('receive_message', {
id: Date.now(),
user: data.user,
message: data.message,
timestamp: new Date().toISOString()
});
});
socket.on('disconnect', () => {
console.log('用户断开:', socket.id);
});
});
httpServer.listen(port, (err) => {
if (err) throw err;
console.log(`> Ready on http://${hostname}:${port}`);
});
});
package.json を修正:
{
"scripts": {
"dev": "node server.js",
"build": "next build",
"start": "NODE_ENV=production node server.js"
}
}
ステップ 2:クライアント接続
Socket.io クライアントロジックを Hook でラップ:
// hooks/useSocket.ts
'use client';
import { useEffect, useState } from 'react';
import io, { Socket } from 'socket.io-client';
export function useSocket() {
const [socket, setSocket] = useState<Socket | null>(null);
const [isConnected, setIsConnected] = useState(false);
useEffect(() => {
const socketInstance = io('http://localhost:3000', {
transports: ['websocket', 'polling'] // 优先 WebSocket,降级到 polling
});
socketInstance.on('connect', () => {
console.log('Socket 已连接');
setIsConnected(true);
});
socketInstance.on('disconnect', () => {
console.log('Socket 已断开');
setIsConnected(false);
});
setSocket(socketInstance);
return () => {
socketInstance.disconnect();
};
}, []);
return { socket, isConnected };
}
ステップ 3:チャットコンポーネント
// app/chat/page.tsx
'use client';
import { useState, useEffect } from 'react';
import { useSocket } from '@/hooks/useSocket';
interface Message {
id: number;
user: string;
message: string;
timestamp: string;
}
export default function ChatPage() {
const { socket, isConnected } = useSocket();
const [messages, setMessages] = useState<Message[]>([]);
const [inputMessage, setInputMessage] = useState('');
const [username] = useState(`用户${Math.floor(Math.random() * 1000)}`);
const roomId = 'general'; // 固定房间,实际项目可以动态生成
useEffect(() => {
if (!socket) return;
// 加入房间
socket.emit('join_room', roomId);
// 监听新消息
socket.on('receive_message', (data: Message) => {
setMessages((prev) => [...prev, data]);
});
return () => {
socket.off('receive_message');
};
}, [socket, roomId]);
const sendMessage = () => {
if (!socket || !inputMessage.trim()) return;
socket.emit('send_message', {
room: roomId,
user: username,
message: inputMessage
});
setInputMessage('');
};
return (
<div className="max-w-2xl mx-auto p-4">
<div className="mb-4">
<span className={`inline-block w-3 h-3 rounded-full ${isConnected ? 'bg-green-500' : 'bg-red-500'}`} />
<span className="ml-2">{isConnected ? '已连接' : '未连接'}</span>
</div>
<div className="border rounded-lg p-4 h-96 overflow-y-auto mb-4 bg-gray-50">
{messages.map((msg) => (
<div key={msg.id} className="mb-2">
<span className="font-semibold text-blue-600">{msg.user}:</span>
<span className="ml-2">{msg.message}</span>
<span className="ml-2 text-xs text-gray-500">
{new Date(msg.timestamp).toLocaleTimeString()}
</span>
</div>
))}
</div>
<div className="flex gap-2">
<input
type="text"
value={inputMessage}
onChange={(e) => setInputMessage(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && sendMessage()}
placeholder="输入消息..."
className="flex-1 border rounded px-3 py-2"
/>
<button
onClick={sendMessage}
disabled={!isConnected}
className="bg-blue-500 text-white px-6 py-2 rounded disabled:bg-gray-300"
>
发送
</button>
</div>
</div>
);
}
踏んだ坑
-
ホットリロード問題:開発中にコードを保存するたび Socket 接続が切れる。Next.js ホットリロードの副作用で完全回避は難しい。慣れるしかない。
-
CORS エラー:クライアントとサーバーのポートが異なる場合、Socket.io 設定に
corsオプションを追加すること。 -
TypeScript 型:
@types/socket.io-clientをインストールしないと型補完が悲惨になる。 -
デプロイ注意:Custom Server は Vercel にデプロイ不可。VPS または WebSocket 対応プラットフォーム(Railway、Render)を使うこと。
SSE(Server-Sent Events)の実装
SSE は Vercel でリアルタイム機能を実現する救いでした。WebSocket よりコードがずっとシンプルで、Custom Server も不要です。
サーバー側:Route Handler で SSE 実装
App Router の Route Handler は ReadableStream を返せる——SSE にぴったり:
// app/api/sse/route.ts
import { NextRequest } from 'next/server';
// 模拟消息队列(实际项目用 Redis Pub/Sub)
const messageQueue: { id: string; message: string }[] = [];
const listeners = new Set<(message: any) => void>();
export async function GET(request: NextRequest) {
// 创建可读流
const stream = new ReadableStream({
start(controller) {
const encoder = new TextEncoder();
// 发送初始连接消息
controller.enqueue(
encoder.encode(`data: ${JSON.stringify({ type: 'connected' })}\n\n`)
);
// 监听新消息
const listener = (message: any) => {
controller.enqueue(
encoder.encode(`data: ${JSON.stringify(message)}\n\n`)
);
};
listeners.add(listener);
// 定期发送心跳,防止连接断开
const heartbeat = setInterval(() => {
controller.enqueue(encoder.encode(`: heartbeat\n\n`));
}, 15000); // 每 15 秒一次
// 清理函数
request.signal.addEventListener('abort', () => {
listeners.delete(listener);
clearInterval(heartbeat);
controller.close();
});
}
});
// 返回 SSE 响应
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive'
}
});
}
// 发送消息的 POST 接口
export async function POST(request: NextRequest) {
const body = await request.json();
const message = {
id: Date.now().toString(),
user: body.user,
message: body.message,
timestamp: new Date().toISOString()
};
// 通知所有监听者
listeners.forEach(listener => listener(message));
return Response.json({ success: true });
}
クライアント側:EventSource で SSE 消費
// app/sse-chat/page.tsx
'use client';
import { useState, useEffect, useRef } from 'react';
interface Message {
id: string;
user: string;
message: string;
timestamp: string;
}
export default function SSEChatPage() {
const [messages, setMessages] = useState<Message[]>([]);
const [inputMessage, setInputMessage] = useState('');
const [isConnected, setIsConnected] = useState(false);
const [username] = useState(`用户${Math.floor(Math.random() * 1000)}`);
const eventSourceRef = useRef<EventSource | null>(null);
useEffect(() => {
// 建立 SSE 连接
const eventSource = new EventSource('/api/sse');
eventSourceRef.current = eventSource;
eventSource.onopen = () => {
console.log('SSE 连接已建立');
setIsConnected(true);
};
eventSource.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.type === 'connected') {
console.log('收到服务端连接确认');
return;
}
// 收到新消息
setMessages((prev) => [...prev, data]);
};
eventSource.onerror = () => {
console.error('SSE 连接错误');
setIsConnected(false);
};
// 清理函数
return () => {
eventSource.close();
};
}, []);
const sendMessage = async () => {
if (!inputMessage.trim()) return;
try {
await fetch('/api/sse', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
user: username,
message: inputMessage
})
});
setInputMessage('');
} catch (error) {
console.error('发送消息失败:', error);
}
};
return (
<div className="max-w-2xl mx-auto p-4">
<div className="mb-4">
<span className={`inline-block w-3 h-3 rounded-full ${isConnected ? 'bg-green-500' : 'bg-red-500'}`} />
<span className="ml-2">{isConnected ? 'SSE 已连接' : 'SSE 未连接'}</span>
</div>
<div className="border rounded-lg p-4 h-96 overflow-y-auto mb-4 bg-gray-50">
{messages.map((msg) => (
<div key={msg.id} className="mb-2">
<span className="font-semibold text-purple-600">{msg.user}:</span>
<span className="ml-2">{msg.message}</span>
<span className="ml-2 text-xs text-gray-500">
{new Date(msg.timestamp).toLocaleTimeString()}
</span>
</div>
))}
</div>
<div className="flex gap-2">
<input
type="text"
value={inputMessage}
onChange={(e) => setInputMessage(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && sendMessage()}
placeholder="输入消息..."
className="flex-1 border rounded px-3 py-2"
/>
<button
onClick={sendMessage}
disabled={!isConnected}
className="bg-purple-500 text-white px-6 py-2 rounded disabled:bg-gray-300"
>
发送
</button>
</div>
</div>
);
}
SSE の実使用感
正直、SSE は完璧な方案ではありません。使っていて気づいた問題:
-
Vercel の 25 秒タイムアウト:Edge Function は 25 秒超で強制切断。クライアントで切断検知後に自動再接続する対応を取りました。
-
ブラウザ接続数制限:同一ドメインで HTTP/1.1 は最大 6 接続。複数タブを開くと詰まることがある。HTTP/2(Vercel デフォルト対応)または単一タブ検知で解決。
-
メッセージブロードキャスト問題:上記コードはシングルインスタンスで動作。Vercel はマルチインスタンス。真のブロードキャストには Redis Pub/Sub または Upstash が必要。
Redis でクロスインスタンスメッセージブロードキャスト
// lib/redis.ts
import { Redis } from '@upstash/redis';
export const redis = new Redis({
url: process.env.UPSTASH_REDIS_REST_URL!,
token: process.env.UPSTASH_REDIS_REST_TOKEN!
});
// app/api/sse/route.ts(改进版)
import { redis } from '@/lib/redis';
export async function GET(request: NextRequest) {
const stream = new ReadableStream({
async start(controller) {
const encoder = new TextEncoder();
// Redis 订阅
const channelName = 'chat_messages';
// 轮询 Redis(Upstash 不支持原生 SUBSCRIBE)
const interval = setInterval(async () => {
const messages = await redis.lrange(channelName, 0, -1);
// 处理消息...
}, 1000);
request.signal.addEventListener('abort', () => {
clearInterval(interval);
controller.close();
});
}
});
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache'
}
});
}
SSE を使うタイミング
私の提案:
- ✅ リアルタイム通知システム
- ✅ 株価プッシュ
- ✅ ログリアルタイム閲覧
- ✅ プログレスバー更新
- ❌ 高頻度双方向チャット(WebSocket を使う)
- ❌ オンラインゲーム(WebSocket を使う)
メッセージ状態とデータ同期
リアルタイム通信は送受信だけではありません。ユーザーはこう聞きます:メッセージは送れた?相手は見た?ネットが切れたらメッセージは消える?
これらの裏側はメッセージ状態管理とデータ同期。チャット機能を作ったとき、ここで数日ハマりました。
メッセージの 4 状態
WeChat の設計を参考に、最低 4 状態:
- 送信中:ユーザーが送信をクリック、サーバー応答前
- 送信済み:サーバーが受信、相手はまだ受け取っていない可能性
- 配信済み:相手のクライアントが受信
- 送信失敗:ネットワークエラーまたはサーバー拒否
シンプルな状態機械で管理:
// types/message.ts
export type MessageStatus = 'sending' | 'sent' | 'delivered' | 'failed';
export interface Message {
id: string;
localId: string; // 客户端生成的临时 ID
user: string;
content: string;
timestamp: string;
status: MessageStatus;
}
楽観的更新 + リトライ機構
サーバー応答を待ってから表示しない——UX が悪い。楽観的更新:送信前に「送信中」状態で表示し、サーバー成功応答後に状態を更新。
// hooks/useChat.ts
'use client';
import { useState } from 'react';
import { Message, MessageStatus } from '@/types/message';
export function useChat() {
const [messages, setMessages] = useState<Message[]>([]);
const sendMessage = async (content: string, username: string) => {
// 生成临时 ID
const localId = `local_${Date.now()}_${Math.random()}`;
// 乐观更新:立刻显示消息
const tempMessage: Message = {
id: '',
localId,
user: username,
content,
timestamp: new Date().toISOString(),
status: 'sending'
};
setMessages((prev) => [...prev, tempMessage]);
try {
// 发送到服务器
const response = await fetch('/api/messages', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ user: username, message: content })
});
const data = await response.json();
// 更新为"已发送"
setMessages((prev) =>
prev.map((msg) =>
msg.localId === localId
? { ...msg, id: data.id, status: 'sent' }
: msg
)
);
} catch (error) {
// 发送失败
setMessages((prev) =>
prev.map((msg) =>
msg.localId === localId ? { ...msg, status: 'failed' } : msg
)
);
}
};
const retryMessage = async (localId: string) => {
const message = messages.find((msg) => msg.localId === localId);
if (!message) return;
// 重置状态为"发送中"
setMessages((prev) =>
prev.map((msg) =>
msg.localId === localId ? { ...msg, status: 'sending' } : msg
)
);
// 重新发送
await sendMessage(message.content, message.user);
};
return { messages, sendMessage, retryMessage };
}
切断再接続とメッセージ永続化
ネットワーク不安定時、接続は切れます。再接続後にメッセージを失わないには?
私の方案:クライアントで IndexedDB に未確認メッセージを保存し、再接続後に再送。
// lib/indexedDB.ts
const DB_NAME = 'ChatDB';
const STORE_NAME = 'pendingMessages';
export async function openDB() {
return new Promise<IDBDatabase>((resolve, reject) => {
const request = indexedDB.open(DB_NAME, 1);
request.onerror = () => reject(request.error);
request.onsuccess = () => resolve(request.result);
request.onupgradeneeded = (event) => {
const db = (event.target as IDBOpenDBRequest).result;
if (!db.objectStoreNames.contains(STORE_NAME)) {
db.createObjectStore(STORE_NAME, { keyPath: 'localId' });
}
};
});
}
export async function savePendingMessage(message: Message) {
const db = await openDB();
const tx = db.transaction(STORE_NAME, 'readwrite');
tx.objectStore(STORE_NAME).add(message);
}
export async function removePendingMessage(localId: string) {
const db = await openDB();
const tx = db.transaction(STORE_NAME, 'readwrite');
tx.objectStore(STORE_NAME).delete(localId);
}
export async function getAllPendingMessages(): Promise<Message[]> {
const db = await openDB();
const tx = db.transaction(STORE_NAME, 'readonly');
const store = tx.objectStore(STORE_NAME);
return new Promise((resolve) => {
const request = store.getAll();
request.onsuccess = () => resolve(request.result);
});
}
実践経験
- 最初から完璧を目指さない:基本送受信を実装してから状態管理を追加。
- 送信失敗を優先:ユーザーが最も気にするのはメッセージが送れたか。既読機能はそれほど重要ではない。
- IndexedDB が救い:ネットワーク不安定時、ローカル保存が命綱になる。
- オフラインシーンをテスト:Chrome DevTools の Network で Offline をシミュレートし、何度も試す。
本番デプロイとパフォーマンス最適化
開発環境では問題なく動き、本番でトラブル——リアルタイム通信で最もよくある遭遇です。坑を減らすための要点を整理しました。
デプロイプラットフォームの選択と制限
前述のとおり、Vercel は WebSocket 非対応。各プラットフォームの詳細:
| プラットフォーム | WebSocket | SSE | 特殊制限 |
|---|---|---|---|
| Vercel | ❌ | ✅ | Edge: 25s タイムアウト;Serverless: 60s タイムアウト |
| Netlify | ❌ | ✅ | Function 10s タイムアウト |
| Railway | ✅ | ✅ | 硬性タイムアウトなし、トラフィック課金 |
| Render | ✅ | ✅ | 無料版スリープ機構 |
| Cloudflare Pages | ❌ | ✅ | Workers に CPU 時間制限 |
| 自前 VPS | ✅ | ✅ | サーバー自己管理 |
推奨:
- 予算が厳しい + 低並行:Vercel + SSE(無料枠大)
- WebSocket 必要:Railway または Render($5〜10/月)
- 高並行 + 予算十分:自前 VPS + Nginx リバースプロキシ
Vercel 上の SSE 最適化
Vercel Edge Function は 25 秒タイムアウト。対策は自動再接続 + ハートビート:
// hooks/useSSE.ts
'use client';
import { useEffect, useRef, useState } from 'react';
export function useSSE(url: string) {
const [isConnected, setIsConnected] = useState(false);
const eventSourceRef = useRef<EventSource | null>(null);
const reconnectTimeoutRef = useRef<NodeJS.Timeout>();
const connect = () => {
const eventSource = new EventSource(url);
eventSourceRef.current = eventSource;
eventSource.onopen = () => {
console.log('SSE 已连接');
setIsConnected(true);
};
eventSource.onerror = () => {
console.error('SSE 连接错误,3 秒后重连...');
setIsConnected(false);
eventSource.close();
// 自动重连
reconnectTimeoutRef.current = setTimeout(() => {
connect();
}, 3000);
};
return eventSource;
};
useEffect(() => {
const eventSource = connect();
return () => {
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
}
eventSource.close();
};
}, [url]);
return { eventSource: eventSourceRef.current, isConnected };
}
マルチインスタンスデプロイのメッセージ同期
Vercel は自動的に複数インスタンスを起動。ユーザー A はインスタンス 1、ユーザー B はインスタンス 2——どうやって相互送信?
答え:Redis Pub/Sub。
Upstash(サーバーレス Redis)で実装した方案:
// lib/redis.ts
import { Redis } from '@upstash/redis';
export const redis = new Redis({
url: process.env.UPSTASH_REDIS_REST_URL!,
token: process.env.UPSTASH_REDIS_REST_TOKEN!
});
// 发送消息到 Redis
export async function publishMessage(channel: string, message: any) {
await redis.lpush(channel, JSON.stringify(message));
await redis.ltrim(channel, 0, 99); // 只保留最近 100 条
}
// 获取消息历史
export async function getRecentMessages(channel: string) {
const messages = await redis.lrange(channel, 0, -1);
return messages.map((m) => JSON.parse(m as string)).reverse();
}
パフォーマンス最適化チェックリスト
-
メッセージ重複排除:クライアントが重複メッセージを受信する可能性。
Setで受信済み ID を記録。 -
仮想スクロール:100 件超えたら
react-windowまたはreact-virtualizedで描画。 -
履歴メッセージの遅延読み込み:全履歴を一度に読み込まず、トップスクロール時に読み込む。
-
レート制限:ユーザーが連続送信するのを防ぎ、クライアントとサーバー両方で制限。
// 简单的客户端限流
let lastSendTime = 0;
const SEND_INTERVAL = 500; // 500ms 内只能发一条
const sendMessage = async (content: string) => {
const now = Date.now();
if (now - lastSendTime < SEND_INTERVAL) {
alert('发送太快了,请稍后再试');
return;
}
lastSendTime = now;
// ... 发送逻辑
};
- 監視とログ:Sentry または LogRocket で SSE 接続失敗、メッセージ送信失敗などを追跡。
コスト管理
リアルタイム機能はコストがかかりやすい。特に WebSocket サービス。節約のコツ:
- WebSocket の代わりに SSE:別途 WebSocket サーバーコストを削減
- メッセージマージプッシュ:1 件ずつプッシュせず、1 秒ごとにバッチ
- Redis は Upstash:リクエスト課金で自前 Redis より安価
- CDN で静的リソース:Next.js 静的リソースを CDN 経由でサーバー負荷軽減
500 人同時オンラインのチャットルームを Vercel + Upstash で運用し、月額 $15 未満。方案選びが鍵でした。
まとめ
記事冒頭の深夜クラッシュ——当時これらを知っていれば、WebSocket に固執しなかった。
Next.js のリアルタイム通信の本質は実用的な選択:
- Vercel デプロイ?SSE を使い、WebSocket を無理に押し込まない
- 予算限り?最もシンプルな方案でまず動かし、最初から技術を積み上げない
- UX 優先?メッセージ状態管理と切断再接続が、派手な技術より重要
本記事のコードはそのまま使えますが、より重要なのは裏側のトレードオフの理解。技術選定に銀の弾丸はなく、プロジェクトに合うものが最良。
リアルタイム機能を作っているなら、遠回りを減らせるはず。質問があればコメントを——できる限り返信します。
次のステップ:
- まずローカルで最もシンプルな SSE サンプルを動かす
- Vercel にデプロイしてテスト
- WebSocket が必要なら Railway または自前ホスティングを検討
技術的負債は恐れるものではない。最初から不要な負債を背負うことが怖い。頑張ってください!
FAQ
Vercel はなぜ WebSocket に対応していないのですか?
解決策は 3 つあります:
• SSE(Server-Sent Events)で代替——Vercel がネイティブサポート
• 独立サーバー(VPS)を借りるか、WebSocket 対応プラットフォーム(Railway、Render)を使う
• サードパーティ WebSocket サービス(Pusher、Ably)——ただしコスト高($49〜99/月)
大多数のシーンでは SSE で十分、かつコストも低いです。
チャットアプリには SSE と WebSocket のどちらが向いていますか?
• SSE が向く:一方向プッシュ中心(リアルタイム通知、チャットルームのメッセージ受信)、Vercel/Netlify など Serverless 上、ユーザー数 1000 未満
• WebSocket が向く:高頻度双方向通信(複数人協調編集、オンラインゲーム)、自前ホスティング、低レイテンシが必要
チャットアプリは多くの場合、SSE(受信)+ HTTP POST(送信)の組み合わせで十分。コストが低く、デプロイもシンプルです。500 人同時オンラインのチャットルームを SSE + Upstash Redis で運用した経験があり、月額 $15 未満でした。
Vercel の 25 秒タイムアウト制限への対処は?
• サーバーからハートビート送信:15 秒ごとに送信し、アイドル判定を防ぐ
• クライアントで切断検知:EventSource の onerror で接続中断を監視
• 自動再接続:切断検知後 3 秒で再接続
• Redis でメッセージ保存:再接続後に未読メッセージを取得
実運用では、ユーザーはほとんど再接続を意識しません。永続接続と同等の体験です。
マルチインスタンスデプロイでメッセージ同期はどう実現しますか?
• ユーザー A が送信 → インスタンス 1 が Redis に書き込み
• インスタンス 1、2、3 が Redis をポーリングして新着を取得
• 各インスタンスが接続ユーザーへプッシュ
Upstash(サーバーレス Redis)を推奨。リクエスト課金で自前 Redis より安価。ポーリング間隔 1 秒でリアルタイム性とコストのバランスを取る。
メッセージを失わない保証は?
• 楽観的更新:送信前に画面に即表示、状態を「送信中」に
• IndexedDB ローカル保存:未確認メッセージをローカル DB に保存
• サーバー応答確認:成功応答後に「送信済み」に更新
• 切断再接続復旧:再接続後 IndexedDB から未確認メッセージを読み取り自動再送
• リトライ:送信失敗メッセージに再送ボタンを表示
WeChat のメッセージ状態設計を参考:送信中、送信済み、配信済み、送信失敗の 4 状態。
Socket.io Custom Server は Vercel にデプロイできますか?
代替案:
• WebSocket 対応プラットフォーム:Railway($5/月〜)、Render($7/月〜)
• 自前 VPS:Vultr、DigitalOcean($5/月〜)
• ハイブリッド:Next.js を Vercel、WebSocket サービスを別途デプロイ
Vercel 必須なら SSE 方式を推奨。大多数のリアルタイム通信要件を満たせます。
リアルタイムチャットアプリのパフォーマンスボトルネックは?
• メッセージリスト描画:100 件超は仮想スクロール(react-window)
• 履歴メッセージ読み込み:遅延読み込み、トップスクロール時にページネーション
• クライアント側レート制限:500ms 以内 1 件のみ送信、スパム防止
• サーバー側レート制限:API ルートに Rate Limiting
• メッセージ重複排除:Set で受信済み ID を記録
• 接続数制限:HTTP/1.1 はドメインあたり最大 6 接続。HTTP/2 または単一タブ検知
監視ツール:Sentry(エラー追跡)、LogRocket(セッション再生)、Vercel Analytics(パフォーマンス監視)。
6分で読めます · 公開日: 2026年1月7日 · 更新日: 2026年6月8日
Next.js 完全ガイド
検索からこのページに来た場合は、前後の記事もあわせて読むと同じテーマの理解がかなり早く深まります。
前の記事
Next.js API 認証とセキュリティ:JWT からレート制限まで完全実践ガイド
JWT 認証、CORS 設定、レート制限、入力検証まで。Next.js API を本番環境で安全に運用するための完全ガイド。最新の脆弱性対策を含め、攻撃からアプリを守る実践コードと手順を解説。
第 19 / 47 記事
次の記事
Next.js ユニットテスト実践:Jest + React Testing Library 完全設定ガイド
Next.js 15 テスト環境をゼロから構築。Jest + React Testing Library の設定、Client/Server Components のテスト、Hook のテスト、Mock テクニック、よくあるトラブルシューティングまで、完全なコード例付きで解説。
第 21 / 47 記事
関連記事
Next.js App Router 入門ガイド:コア概念と基本操作を解説
Next.js App Router 入門ガイド:コア概念と基本操作を解説
Next.js 15 実践:週末で本番級ブログシステムを構築した方法
Next.js 15 実践:週末で本番級ブログシステムを構築した方法
Next.js Middleware 実践ガイド:パスマッチ、Edge Runtime 制限とよくある落とし穴
コメント
GitHubアカウントでログインしてコメントできます