言語を切り替える
テーマを切り替える

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 接続オーバーヘッド
1 回のハンドシェイク、永続接続
シンプル
SSE 実装の複雑さ
ブラウザネイティブ EventSource 対応
全部
Long Polling 互換性
すべてのブラウザをサポート
SSE 優先
Vercel デプロイ適性
WebSocket 非対応
特性WebSocketSSELong Polling
双方向通信❌(POST と併用)
Vercel 対応✅(タイムアウト制限あり)
ブラウザ互換モダンブラウザモダンブラウザ全部
接続オーバーヘッド
実装の複雑さ中程度シンプル非常にシンプル
向くシーンチャット、ゲーム、協調編集通知、リアルタイム更新低頻度メッセージ、高互換性要件

気づいたかもしれません:Next.js + Vercel の組み合わせでは、SSE と Long Polling が主流。技術の後退ではなく、プラットフォーム制限の中で見つけた実用的な方案です。

Next.js 環境でのリアルタイム通信の選定

3 方式を知ることと、どれを選ぶかは別問題。ここでは、坑を踏んだ後にようやく理解した判断ポイントをまとめます。

デプロイプラットフォームが最初の分岐点

Vercel、Netlify、Cloudflare Pages など Serverless プラットフォームを使うなら、WebSocket はほぼ無理です。選択肢は 3 つ:

  1. SSE 方式:一方向プッシュ向け(通知、リアルタイム更新、チャットルームのメッセージ受信)
  2. Long Polling:低頻度の双方向通信向け
  3. 独立 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>
  );
}

踏んだ坑

  1. ホットリロード問題:開発中にコードを保存するたび Socket 接続が切れる。Next.js ホットリロードの副作用で完全回避は難しい。慣れるしかない。

  2. CORS エラー:クライアントとサーバーのポートが異なる場合、Socket.io 設定に cors オプションを追加すること。

  3. TypeScript 型@types/socket.io-client をインストールしないと型補完が悲惨になる。

  4. デプロイ注意: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 は完璧な方案ではありません。使っていて気づいた問題:

  1. Vercel の 25 秒タイムアウト:Edge Function は 25 秒超で強制切断。クライアントで切断検知後に自動再接続する対応を取りました。

  2. ブラウザ接続数制限:同一ドメインで HTTP/1.1 は最大 6 接続。複数タブを開くと詰まることがある。HTTP/2(Vercel デフォルト対応)または単一タブ検知で解決。

  3. メッセージブロードキャスト問題:上記コードはシングルインスタンスで動作。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 状態:

  1. 送信中:ユーザーが送信をクリック、サーバー応答前
  2. 送信済み:サーバーが受信、相手はまだ受け取っていない可能性
  3. 配信済み:相手のクライアントが受信
  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);
  });
}

実践経験

  1. 最初から完璧を目指さない:基本送受信を実装してから状態管理を追加。
  2. 送信失敗を優先:ユーザーが最も気にするのはメッセージが送れたか。既読機能はそれほど重要ではない。
  3. IndexedDB が救い:ネットワーク不安定時、ローカル保存が命綱になる。
  4. オフラインシーンをテスト:Chrome DevTools の Network で Offline をシミュレートし、何度も試す。

本番デプロイとパフォーマンス最適化

開発環境では問題なく動き、本番でトラブル——リアルタイム通信で最もよくある遭遇です。坑を減らすための要点を整理しました。

デプロイプラットフォームの選択と制限

前述のとおり、Vercel は WebSocket 非対応。各プラットフォームの詳細:

プラットフォームWebSocketSSE特殊制限
VercelEdge: 25s タイムアウト;Serverless: 60s タイムアウト
NetlifyFunction 10s タイムアウト
Railway硬性タイムアウトなし、トラフィック課金
Render無料版スリープ機構
Cloudflare PagesWorkers に 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();
}

パフォーマンス最適化チェックリスト

  1. メッセージ重複排除:クライアントが重複メッセージを受信する可能性。Set で受信済み ID を記録。

  2. 仮想スクロール:100 件超えたら react-window または react-virtualized で描画。

  3. 履歴メッセージの遅延読み込み:全履歴を一度に読み込まず、トップスクロール時に読み込む。

  4. レート制限:ユーザーが連続送信するのを防ぎ、クライアントとサーバー両方で制限。

// 简单的客户端限流
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;
  // ... 发送逻辑
};
  1. 監視とログ: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 優先?メッセージ状態管理と切断再接続が、派手な技術より重要

本記事のコードはそのまま使えますが、より重要なのは裏側のトレードオフの理解。技術選定に銀の弾丸はなく、プロジェクトに合うものが最良。

リアルタイム機能を作っているなら、遠回りを減らせるはず。質問があればコメントを——できる限り返信します。

次のステップ:

  1. まずローカルで最もシンプルな SSE サンプルを動かす
  2. Vercel にデプロイしてテスト
  3. WebSocket が必要なら Railway または自前ホスティングを検討

技術的負債は恐れるものではない。最初から不要な負債を背負うことが怖い。頑張ってください!

FAQ

Vercel はなぜ WebSocket に対応していないのですか?
Vercel は Serverless アーキテクチャを採用しており、アプリはクラウド関数上で動きます。クラウド関数はリクエスト終了後すぐにリソースを回収するため、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 秒タイムアウト制限への対処は?
Vercel Edge Function には 25 秒タイムアウトがあります。解決策はクライアント側の自動再接続です:

• サーバーからハートビート送信:15 秒ごとに送信し、アイドル判定を防ぐ
• クライアントで切断検知:EventSource の onerror で接続中断を監視
• 自動再接続:切断検知後 3 秒で再接続
• Redis でメッセージ保存:再接続後に未読メッセージを取得

実運用では、ユーザーはほとんど再接続を意識しません。永続接続と同等の体験です。
マルチインスタンスデプロイでメッセージ同期はどう実現しますか?
Vercel は自動的に複数インスタンスを起動し、ユーザーは異なるインスタンスに接続する可能性があります。解決策は Redis Pub/Sub です:

• ユーザー A が送信 → インスタンス 1 が Redis に書き込み
• インスタンス 1、2、3 が Redis をポーリングして新着を取得
• 各インスタンスが接続ユーザーへプッシュ

Upstash(サーバーレス Redis)を推奨。リクエスト課金で自前 Redis より安価。ポーリング間隔 1 秒でリアルタイム性とコストのバランスを取る。
メッセージを失わない保証は?
ネットワーク問題でメッセージが失われることがあります。完全な解決策:

• 楽観的更新:送信前に画面に即表示、状態を「送信中」に
• IndexedDB ローカル保存:未確認メッセージをローカル DB に保存
• サーバー応答確認:成功応答後に「送信済み」に更新
• 切断再接続復旧:再接続後 IndexedDB から未確認メッセージを読み取り自動再送
• リトライ:送信失敗メッセージに再送ボタンを表示

WeChat のメッセージ状態設計を参考:送信中、送信済み、配信済み、送信失敗の 4 状態。
Socket.io Custom Server は Vercel にデプロイできますか?
できません。Custom Server は長時間稼働する Node.js プロセスが必要ですが、Vercel は Serverless 関数のみサポートします。

代替案:
• 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日

関連記事

コメント

GitHubアカウントでログインしてコメントできます