Changer le thème

Application de chat temps réel Next.js : WebSocket et SSE, la bonne approche

Easton editorial illustration: performance inspection lens

Troisième tentative pour faire tourner WebSocket dans Next.js. La page affiche : « WebSocket is not supported in this environment ». Le chat marchait en local ; déploiement sur Vercel, tout casse.

J’ai alors compris : la communication temps réel avec Next.js n’est pas aussi simple qu’il y paraît.

Cet article ne vous montrera pas comment forcer WebSocket dans Next.js — j’ai essayé, ça ne marche pas. Je partage les pièges réels : pourquoi Vercel refuse WebSocket, si SSE peut sauver la mise, comment faire cohabiter Socket.io et App Router. Si vous galérez sur le temps réel en Next.js, j’espère vous éviter quelques détours.

Comparaison des trois approches temps réel

Salles de chat, édition collaborative, notifications — tout cela exige que le serveur pousse des données au client. Le modèle requête-réponse HTTP ne suffit pas ; voici les trois options dominantes.

WebSocket : le rêve full duplex

WebSocket est l’idéal — une connexion, puis échanges libres dans les deux sens. Alors pourquoi hésiter ?

Moi aussi je pensais ainsi. Jusqu’au déploiement.

Vercel, Netlify et les plateformes Serverless ne supportent pas les connexions longues. Votre app Next.js tourne sur des fonctions cloud ; la requête finie, la fonction disparaît — impossible de garder WebSocket. J’ai testé Pusher et Ably : les tarifs mensuels m’ont freiné.

WebSocket reste utilisable : serveur loué ou backend Node.js dédié — mais coût et architecture augmentent.

SSE : le secours unidirectionnel

Server-Sent Events : unidirectionnel, le serveur pousse vers le client.

Ça peut sembler limité — envoyer en HTTP POST, recevoir en SSE. Mais ça convient à beaucoup de cas : en chat, l’envoi est actif, la réception passive ; les notifications n’ont besoin que du push serveur.

Surtout : SSE fonctionne sur Vercel.

J’ai fait un système de notifications simple avec SSE et résolu le blocage Vercel. Limites oui (Edge Function, timeout 25 s), mais suffisant pour le push de messages.

Long Polling : vieux mais efficace

Le plus ancien schéma : le client demande, le serveur attend un message puis répond, le client relance aussitôt.

Performance médiocre, trafic gaspillé. Mais pour quelques centaines d’utilisateurs et peu de messages, Long Polling tient la route — code simple, compatibilité totale, Serverless OK.

J’ai vu des petits projets tenir des mois avec Long Polling, sans plainte utilisateur. Ne laissez pas la « dette technique » vous paralyser : ce qui résout le problème est bon.

Tableau comparatif

Faible
Coût connexion WebSocket
Une poignée de main, connexion persistante
Simple
Complexité SSE
EventSource natif au navigateur
Total
Compatibilité Long Polling
Tous les navigateurs
SSE en tête
Facilité sur Vercel
WebSocket non supporté
CaractéristiqueWebSocketSSELong Polling
Bidirectionnel❌ (avec POST)
Support Vercel✅ (timeout)
Compatibilité navigateurNavigateurs modernesNavigateurs modernesTous
Coût connexionFaibleFaibleÉlevé
ComplexitéMoyenneSimpleTrès simple
Cas d’usageChat, jeux, édition collaborativeNotifications, mises à jourMessages peu fréquents, compatibilité max

À retenir : avec Next.js + Vercel, SSE et Long Polling dominent. Ce n’est pas un recul technique, c’est un choix pragmatique face aux contraintes de plateforme.

Choisir une solution temps réel dans Next.js

Connaître les trois options est une chose ; en choisir une en est une autre. Voici des critères appris à la dure.

La plateforme de déploiement tranche d’abord

Sur Vercel, Netlify, Cloudflare Pages Serverless, WebSocket est quasi hors jeu. Trois voies :

  1. SSE : push unidirectionnel (notifications, mises à jour, réception en salle de chat)
  2. Long Polling : bidirectionnel à faible fréquence
  3. Service WebSocket séparé : petit serveur dédié, app Next.js restant sur Serverless

Sauf communication bidirectionnelle très fréquente (édition collaborative multi-utilisateurs), je choisis SSE — déploiement simple, budget préservé.

En VPS ou Docker auto-hébergé, WebSocket est libre — mais load balancing et gestion des processus vous incombent.

Le volume d’utilisateurs fixe la complexité

Combien d’utilisateurs simultanés ?

  • < 100 : Long Polling suffit, implémentable en une heure
  • 100-1 000 : SSE ou WebSocket simple
  • > 1 000 : file de messages (Redis Pub/Sub), load balancing, multi-instances

Une équipe startup a lancé en Long Polling, basculé en SSE à 500 utilisateurs — validation rapide d’abord, perf ensuite.

La fréquence des messages oriente le choix

Quelques messages par minute : Long Polling OK. Chat actif à plusieurs dizaines de messages simultanés : SSE ou WebSocket.

Point souvent oublié : limite de connexions HTTP par domaine (souvent 6). Long Polling ou SSE sur plusieurs onglets peuvent bloquer — WebSocket ou détection mono-onglet.

Le budget compte aussi

Pusher, Ably, PubNub facturent au volume. Pour 500 utilisateurs en ligne, comptez 49-99 $/mois rien que pour WebSocket.

En auto-hébergement :

  • Vercel + SSE : large quota gratuit, petits projets à 0 $
  • VPS + WebSocket : à partir de 5 $/mois (Vultr, DigitalOcean)
  • Railway/Render : WebSocket supporté, 5-10 $/mois

Mon arbre de décision (référence)

Déployé sur Vercel ?
├─ Oui → Utilisateurs > 1 000 ?
│   ├─ Oui → SSE + Redis Pub/Sub
│   └─ Non → SSE simple ou Long Polling
└─ Non (auto-hébergé) → Bidirectionnel haute fréquence ?
    ├─ Oui → WebSocket + Socket.io
    └─ Non → SSE

En fin de compte, pas de choix absolu. Mon expérience : partir du plus simple, optimiser quand ça coince. Beaucoup de projets WebSocket dès le jour 1 n’atteignent jamais 100 utilisateurs.

Intégration Socket.io en pratique

Si vous optez pour WebSocket (serveur loué), Socket.io est la lib la plus mature : dégradation automatique vers Long Polling, reconnexion, gestion des salles.

Dans Next.js, les pièges sont nombreux. Les Route Handlers App Router n’exposent pas res.socket — il faut un Custom Server.

Étape 1 : créer un Custom Server

Le démarrage par défaut de Next.js ne gère pas WebSocket. Créez server.js à la racine :

// 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}`);
  });
});

Modifiez package.json :

{
  "scripts": {
    "dev": "node server.js",
    "build": "next build",
    "start": "NODE_ENV=production node server.js"
  }
}

Étape 2 : connexion client

Un Hook pour encapsuler le client Socket.io :

// 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 };
}

Étape 3 : composant de chat

// 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>
  );
}

Pièges que j’ai rencontrés

  1. Hot reload : à chaque sauvegarde en dev, la connexion Socket se coupe — effet secondaire du hot reload Next.js, difficile à éviter.

  2. Erreurs CORS : ports client/serveur différents → configurez cors dans Socket.io.

  3. Types TypeScript : installez @types/socket.io-client pour de bons autocomplétions.

  4. Déploiement : Custom Server impossible sur Vercel — VPS ou Railway/Render.

Implémentation SSE (Server-Sent Events)

SSE a sauvé mes fonctionnalités temps réel sur Vercel. Code plus simple que WebSocket, pas de Custom Server.

Côté serveur : Route Handler SSE

Les Route Handlers App Router peuvent renvoyer un ReadableStream, idéal pour 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 });
}

Côté client : consommer SSE avec EventSource

// 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>
  );
}

Retour d’expérience SSE

SSE n’est pas parfait. Problèmes rencontrés :

  1. Timeout 25 s sur Vercel : l’Edge Function coupe au-delà de 25 s — reconnexion automatique côté client.

  2. Limite de connexions navigateur : 6 connexions HTTP/1.1 par domaine — plusieurs onglets peuvent bloquer. HTTP/2 (Vercel par défaut) ou détection mono-onglet.

  3. Diffusion des messages : le code ci-dessus marche en mono-instance ; sur Vercel multi-instances, il faut Redis Pub/Sub ou Upstash.

Diffusion inter-instances avec 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'
    }
  });
}

Quand utiliser SSE ?

Mon avis :

  • ✅ Notifications temps réel
  • ✅ Cours boursiers
  • ✅ Logs en direct
  • ✅ Barres de progression
  • ❌ Chat bidirectionnel à haute fréquence (WebSocket)
  • ❌ Jeux en ligne (WebSocket)

États des messages et synchronisation

Le temps réel, ce n’est pas seulement envoyer et recevoir. Les utilisateurs demandent : mon message est-il parti ? vu ? que se passe-t-il si le réseau coupe ?

Derrière tout cela : gestion d’état et synchronisation. J’ai bloqué plusieurs jours sur mon chat.

Quatre états de message

Inspiré de WeChat, au minimum :

  1. Envoi en cours : clic sur envoyer, pas encore de réponse serveur
  2. Envoyé : serveur OK, destinataire pas forcément notifié
  3. Distribué : client destinataire a reçu
  4. Échec : erreur réseau ou refus serveur

Machine à états simple :

// 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;
}

Mise à jour optimiste + nouvelles tentatives

N’attendez pas le serveur pour afficher — mauvaise UX. Mise à jour optimiste : afficher « envoi en cours », puis mettre à jour après succès.

// 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 };
}

Reconnexion et persistance

Réseau instable → coupure. Comment ne rien perdre après reconnexion ?

Ma solution : IndexedDB pour les messages non confirmés, renvoi automatique après reconnexion.

// 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);
  });
}

Retour terrain

  1. Ne visez pas la perfection d’emblée : envoi/réception d’abord, états ensuite.
  2. Priorité aux échecs d’envoi : l’utilisateur veut savoir si le message est parti — « lu » peut attendre.
  3. IndexedDB sauve : réseau faible, le local compense.
  4. Testez hors ligne : Chrome DevTools → Network → Offline, plusieurs fois.

Déploiement production et optimisation

Tout marche en dev, tout casse en prod — classique en temps réel. Quelques points pour limiter les dégâts.

Plateformes et limites

Rappel : Vercel refuse WebSocket. Détail par plateforme :

PlateformeWebSocketSSELimites
VercelEdge : 25 s ; Serverless : 60 s
NetlifyFunction 10 s
RailwayPas de timeout dur, facturation au trafic
RenderSommeil sur l’offre gratuite
Cloudflare PagesLimite CPU Workers
VPS auto-hébergéGestion serveur à votre charge

Recommandations :

  • Budget serré + faible concurrence : Vercel + SSE (quota gratuit large)
  • WebSocket requis : Railway ou Render (5-10 $/mois)
  • Forte concurrence + budget : VPS + reverse proxy Nginx

Optimiser SSE sur Vercel

Edge Function limitée à 25 s : reconnexion auto + heartbeat :

// 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 };
}

Synchronisation multi-instances

Vercel multiplie les instances. Utilisateur A sur l’instance 1, B sur l’instance 2 — comment échanger ?

Réponse : Redis Pub/Sub.

Avec Upstash (Redis serverless) :

// 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();
}

Checklist performance

  1. Déduplication : le client peut recevoir des doublons — Set des ID déjà vus.

  2. Défilement virtuel : au-delà de 100 messages, react-window ou react-virtualized.

  3. Historique paresseux : ne pas tout charger ; pagination en haut de liste.

  4. Limitation de débit : client et serveur pour éviter le spam.

// 简单的客户端限流
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. Monitoring : Sentry ou LogRocket pour échecs SSE et envois.

Maîtriser les coûts

Le temps réel coûte cher, surtout WebSocket :

  • SSE plutôt que WebSocket : pas de serveur WebSocket dédié
  • Regrouper les push : batch par seconde plutôt qu’un push par message
  • Upstash pour Redis : facturation à la requête, moins cher qu’un Redis maison
  • CDN pour le statique : alléger le serveur Next.js

Salle de 500 utilisateurs, Vercel + Upstash : moins de 15 $/mois — le bon choix d’architecture compte.

Conclusion

Revenons à cette nuit où tout a cassé — avec ces connaissances, je n’aurais pas insisté sur WebSocket.

Le temps réel en Next.js, c’est surtout choisir pragmatiquement :

  • Sur Vercel ? SSE, pas WebSocket forcé
  • Budget limité ? le plus simple d’abord, pas la stack la plus flashy
  • UX d’abord ? états de message et reconnexion valent mieux que la démo technique

Le code de cet article est réutilisable ; l’important est de comprendre les compromis. Pas de solution universelle — celle qui convient à votre projet.

Si vous travaillez sur du temps réel, j’espère vous éviter mes détours. Questions en commentaire, je répondrai si possible.

Prochaines étapes :

  1. Faire tourner un exemple SSE minimal en local
  2. Déployer sur Vercel pour tester
  3. WebSocket seulement si besoin — Railway ou auto-hébergement

La dette technique n’est pas grave ; ce qui l’est, c’est en prendre inutilement dès le départ. Bon courage !

FAQ

Pourquoi Vercel ne supporte-t-il pas WebSocket ?
Vercel repose sur une architecture Serverless : l'application tourne sur des fonctions cloud. Une fois la requête terminée, les ressources sont libérées — impossible de maintenir la connexion longue exigée par WebSocket.

Trois solutions :
• Utiliser SSE (Server-Sent Events), nativement supporté par Vercel
• Louer un serveur dédié (VPS) ou une plateforme compatible WebSocket (Railway, Render)
• Recourir à un service WebSocket tiers (Pusher, Ably), mais coûteux (49-99 $/mois)

Pour la plupart des cas, SSE suffit et coûte moins cher.
SSE ou WebSocket pour une application de chat ?
Cela dépend de la plateforme et des besoins :

• SSE convient aux scénarios surtout unidirectionnels (notifications temps réel, réception de messages en salle), déploiement sur Vercel/Netlify Serverless, moins de 1 000 utilisateurs
• WebSocket convient à la communication bidirectionnelle à haute fréquence (édition collaborative, jeux en ligne), serveur auto-hébergé, faible latence

En chat, la combinaison SSE (réception) + HTTP POST (envoi) est souvent suffisante — moins chère et plus simple. J'ai tenu une salle de 500 utilisateurs avec SSE + Upstash Redis pour moins de 15 $/mois.
Comment gérer la limite de 25 secondes sur Vercel ?
Les Edge Functions Vercel expirent au bout de 25 s. La solution : reconnexion automatique côté client :

• Heartbeat serveur : envoi toutes les 15 s pour éviter une connexion jugée inactive
• Détection côté client : l'événement onerror d'EventSource signale la coupure
• Reconnexion auto : nouvelle connexion 3 s après la détection
• Redis pour les messages : retrouver les non lus après reconnexion

En pratique, l'utilisateur ne remarque presque pas la reconnexion — expérience proche d'une connexion persistante.
Comment synchroniser les messages en déploiement multi-instances ?
Vercel crée plusieurs instances ; les utilisateurs peuvent être sur des instances différentes. Solution : Redis Pub/Sub :

• L'utilisateur A envoie → l'instance 1 écrit dans Redis
• Les instances 1, 2, 3 interrogent Redis pour les nouveaux messages
• Chaque instance pousse aux clients qui lui sont connectés

Upstash (Redis serverless) facturé à la requête, moins cher qu'un Redis auto-hébergé. Intervalle de polling à 1 s pour équilibrer temps réel et coût.
Comment garantir qu'aucun message ne se perd ?
Les messages peuvent disparaître à cause du réseau. Solution complète :

• Mise à jour optimiste : affichage immédiat avec statut « envoi en cours »
• IndexedDB : messages non confirmés en base locale
• Accusé serveur : passage à « envoyé » après réponse OK
• Reprise après reconnexion : relire IndexedDB et renvoyer automatiquement
• Nouvelle tentative : bouton de renvoi pour les échecs

Inspiré des états WeChat : envoi en cours, envoyé, distribué, échec.
Peut-on déployer un Custom Server Socket.io sur Vercel ?
Non. Un Custom Server exige un processus Node.js longue durée ; Vercel ne propose que des fonctions Serverless.

Alternatives :
• Plateformes WebSocket : Railway (à partir de 5 $/mois), Render (à partir de 7 $/mois)
• VPS auto-hébergé : Vultr, DigitalOcean (à partir de 5 $/mois)
• Déploiement hybride : Next.js sur Vercel, service WebSocket à part

Si Vercel est obligatoire, préférez SSE — cela couvre la plupart des besoins temps réel.
Où sont les goulots d'étranglement d'une app de chat temps réel ?
Goulots courants et optimisations :

• Rendu de la liste : défilement virtuel (react-window) au-delà de 100 messages
• Historique : chargement paresseux, pagination en haut de liste
• Limitation client : une seule message toutes les 500 ms
• Limitation serveur : rate limiting sur les routes API
• Déduplication : Set des ID déjà reçus
• Limite de connexions : 6 connexions HTTP/1.1 par domaine — HTTP/2 ou détection mono-onglet

Outils : Sentry (erreurs), LogRocket (replay de session), Vercel Analytics (performance).

16 min de lecture · Publié le: 7 janv. 2026 · Mis à jour le: 27 juil. 2026

Commentaires

Connectez-vous avec GitHub pour laisser un commentaire

Easton BlogEaston Blog