Design wechseln

Next.js Echtzeit-Chat: WebSocket und SSE richtig nutzen

Easton editorial illustration: performance inspection lens

Beim dritten Versuch, WebSocket in Next.js zum Laufen zu bringen, meldete die Seite: „WebSocket is not supported in this environment“. Lokal lief der Chat gerade noch – nach dem Deploy auf Vercel war alles kaputt.

Dann wurde mir klar: Echtzeit in Next.js ist deutlich komplizierter, als es auf den ersten Blick wirkt.

Dieser Artikel zeigt nicht, wie man WebSocket mit Gewalt in Next.js presst (den Weg bin ich gegangen – er führt ins Leere). Stattdessen: echte Fallstricke – warum Vercel kein WebSocket unterstützt, ob SSE die Rettung ist und wie Socket.io mit dem App Router zusammenarbeitet. Wenn Sie an Next.js-Echtzeitfunktionen verzweifeln, sollten Sie hier weniger Umwege machen.

Drei Echtzeit-Protokolle im Vergleich

Chat, kollaboratives Editing, Live-Benachrichtigungen – der Server muss Daten aktiv an den Client pushen. Das klassische HTTP Request-Response-Modell reicht nicht; es gibt drei etablierte Ansätze.

WebSocket: Voll duplex im Traum

WebSocket ist ideal: eine Verbindung, Client und Server senden jederzeit. Warum noch zögern?

Genau so dachte ich – bis zum Deploy.

Vercel, Netlify und andere Serverless-Plattformen unterstützen keine Langzeitverbindungen. Ihre Next.js-App läuft in Cloud Functions – nach der Antwort wird die Function beendet. Eine WebSocket-Verbindung bleibt nicht offen. Drittanbieter wie Pusher oder Ably funktionieren, aber die monatlichen Kosten haben mich abgeschreckt.

WebSocket ist trotzdem möglich: eigener Server oder separates Node.js-Backend nur für WebSocket – höhere Kosten und komplexere Architektur.

SSE: Einweg als Notlösung

Server-Sent Events – der Name sagt es: nur Server → Client.

Das klingt mager – Senden per HTTP POST, Empfang per SSE. Für viele Fälle reicht das: Im Chat senden Sie aktiv, empfangen passiv; Benachrichtigungen brauchen ohnehin nur Server-Push.

Entscheidend: SSE läuft auf Vercel.

Ich habe ein einfaches Benachrichtigungssystem mit SSE gebaut und das Vercel-Problem gelöst. Es gibt Limits (25-Sekunden-Timeout bei Edge Functions), für Push reicht es aber.

Long Polling: alt, aber robust

Der älteste Ansatz: Client fragt an, Server antwortet erst bei neuer Nachricht, Client fragt sofort wieder.

Schlechte Performance, mehr Traffic. Bei wenigen hundert Nutzern und seltenen Nachrichten ist Long Polling oft ausreichend – einfacher Code, keine Kompatibilitätsprobleme, Serverless-freundlich.

Viele kleine Projekte laufen damit – Nutzer merken wenig. Nicht jede „technische Schuld“ ist schlecht; was funktioniert, ist gut.

Vergleichstabelle

Niedrig
WebSocket-Verbindungsaufwand
Ein Handshake, dauerhafte Verbindung
Einfach
SSE-Implementierung
EventSource nativ im Browser
Alle
Long-Polling-Kompatibilität
Jeder Browser
SSE zuerst
Vercel-Deployment
WebSocket nicht unterstützt
EigenschaftWebSocketSSELong Polling
Bidirektional❌ (mit POST)
Vercel✅ (mit Timeout)
BrowserModernModernAlle
VerbindungsaufwandNiedrigNiedrigHoch
KomplexitätMittelEinfachSehr einfach
SzenarioChat, Spiele, KollaborationBenachrichtigungen, Live-UpdatesSeltene Nachrichten, maximale Kompatibilität

Unter Next.js + Vercel sind SSE und Long Polling die üblichen Wege – kein technischer Rückschritt, sondern pragmatische Wahl unter Plattform-Limits.

Auswahl unter Next.js

Drei Protokolle zu kennen ist das eine; das Richtige zu wählen das andere. Hier Entscheidungspunkte aus eigener Erfahrung.

Deployment-Plattform als erste Hürde

Auf Vercel, Netlify, Cloudflare Pages ist WebSocket praktisch ausgeschlossen. Drei Wege:

  1. SSE: unidirektionales Push (Benachrichtigungen, Live-Updates, Chat-Empfang)
  2. Long Polling: seltene bidirektionale Kommunikation
  3. Separater WebSocket-Service: kleiner Server nur für WebSocket, Next.js bleibt auf Serverless

Außer bei echt hochfrequenter bidirektionaler Kommunikation (z. B. Multi-User-Editing) nutze ich SSE – einfaches Deployment, geringe Kosten.

Mit eigenem VPS oder Docker ist WebSocket unkompliziert – dafür Load Balancing und Prozess-Management selbst.

Nutzerzahl und Architektur

Wie viele Nutzer sind gleichzeitig online?

  • < 100: Long Polling reicht, in einer Stunde implementierbar
  • 100–1000: SSE oder einfaches WebSocket
  • > 1000: Message Queue (Redis Pub/Sub), Load Balancing, mehrere Instanzen

Ein Startup startete mit Long Polling, wechselte bei 500 Nutzern zu SSE – sinnvoller Weg: erst validieren, dann optimieren.

Nachrichtenfrequenz

Bei wenigen Nachrichten pro Minute reicht Long Polling. Bei aktivem Chat mit vielen gleichzeitigen Sendern: SSE oder WebSocket.

Browser-Limit: oft nur 6 HTTP-Verbindungen pro Domain. Mehrere Tabs mit Long Polling oder SSE können blockieren – dann WebSocket oder Single-Tab-Erkennung.

Budget

Pusher, Ably, PubNub sind bequem, aber nach Volumen abrechenbar. Ein 500-Nutzer-Chatraum kostete mich $49–99/Monat nur für WebSocket.

Eigenes Setup:

  • Vercel + SSE: großzügiges Free-Tier
  • VPS + WebSocket: ab ca. $5/Monat (Vultr, DigitalOcean)
  • Railway/Render: WebSocket, ca. $5–10/Monat

Entscheidungsbaum (Orientierung)

Deploy auf Vercel?
├─ Ja → Nutzer > 1000?
│   ├─ Ja → SSE + Redis Pub/Sub
│   └─ Nein → einfaches SSE oder Long Polling
└─ Nein (Self-Hosting) → häufige bidirektionale Kommunikation?
    ├─ Ja → WebSocket + Socket.io
    └─ Nein → SSE

Keine absolute Wahrheit – einfachste Lösung zuerst, optimieren wenn es wirklich nötig ist. Viele WebSocket-Cluster-Projekte erreichen nie 100 Nutzer.

Socket.io in der Praxis

Wer WebSocket nutzt (z. B. eigener Server), ist Socket.io oft die erste Wahl: Auto-Fallback auf Long Polling, Reconnect, Räume.

In Next.js gibt es mehr Fallstricke: App Router Route Handler unterstützen kein res.socket – Sie brauchen einen Custom Server.

Schritt 1: Custom Server

Der Standard-Start unterstützt kein WebSocket. Im Projektroot 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 initialisieren
  const io = new Server(httpServer, {
    cors: {
      origin: dev ? 'http://localhost:3000' : 'https://yourdomain.com',
      methods: ['GET', 'POST']
    }
  });

  io.on('connection', (socket) => {
    console.log('Nutzer verbunden:', socket.id);

    socket.on('join_room', (roomId) => {
      socket.join(roomId);
      console.log(`Nutzer ${socket.id} ist Raum ${roomId} beigetreten`);
    });

    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('Nutzer getrennt:', socket.id);
    });
  });

  httpServer.listen(port, (err) => {
    if (err) throw err;
    console.log(`> Ready on http://${hostname}:${port}`);
  });
});

package.json anpassen:

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

Schritt 2: Client-Verbindung

Hook für den Socket.io-Client:

// 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 zuerst, Fallback polling
    });

    socketInstance.on('connect', () => {
      console.log('Socket verbunden');
      setIsConnected(true);
    });

    socketInstance.on('disconnect', () => {
      console.log('Socket getrennt');
      setIsConnected(false);
    });

    setSocket(socketInstance);

    return () => {
      socketInstance.disconnect();
    };
  }, []);

  return { socket, isConnected };
}

Schritt 3: Chat-Komponente

// 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(`Nutzer${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 ? 'Verbunden' : 'Getrennt'}</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="Nachricht eingeben..."
          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"
        >
          Senden
        </button>
      </div>
    </div>
  );
}

Fallstricke

  1. Hot Reload: Beim Speichern bricht die Socket-Verbindung ab – Nebenwirkung von Next.js, kaum vermeidbar.

  2. CORS: Bei unterschiedlichen Ports cors in der Socket.io-Konfiguration setzen.

  3. TypeScript: @types/socket.io-client installieren.

  4. Deploy: Custom Server nicht auf Vercel – VPS oder Railway/Render.

SSE (Server-Sent Events)

SSE war meine Rettung für Echtzeit auf Vercel – deutlich einfacher als WebSocket, kein Custom Server.

Server: Route Handler mit SSE

Der App Router kann ReadableStream zurückgeben – ideal für SSE:

// app/api/sse/route.ts
import { NextRequest } from 'next/server';

// Demo-Warteschlange (Produktion: 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);

      request.signal.addEventListener('abort', () => {
        listeners.delete(listener);
        clearInterval(heartbeat);
        controller.close();
      });
    }
  });

  return new Response(stream, {
    headers: {
      'Content-Type': 'text/event-stream',
      'Cache-Control': 'no-cache',
      'Connection': 'keep-alive'
    }
  });
}

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

Client: 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(`Nutzer${Math.floor(Math.random() * 1000)}`);
  const eventSourceRef = useRef<EventSource | null>(null);

  useEffect(() => {
    const eventSource = new EventSource('/api/sse');
    eventSourceRef.current = eventSource;

    eventSource.onopen = () => {
      console.log('SSE verbunden');
      setIsConnected(true);
    };

    eventSource.onmessage = (event) => {
      const data = JSON.parse(event.data);

      if (data.type === 'connected') {
        console.log('Verbindungsbestätigung vom Server');
        return;
      }

      setMessages((prev) => [...prev, data]);
    };

    eventSource.onerror = () => {
      console.error('SSE-Fehler');
      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('Senden fehlgeschlagen:', 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 verbunden' : 'SSE getrennt'}</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="Nachricht eingeben..."
          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"
        >
          Senden
        </button>
      </div>
    </div>
  );
}

Erfahrungen mit SSE

SSE ist nicht perfekt:

  1. 25-Sekunden-Timeout auf Vercel: Edge Functions werden getrennt – Client erkennt Abbruch und verbindet neu.

  2. Verbindungslimit: max. 6 HTTP/1.1-Verbindungen pro Domain – mehrere Tabs können hängen. HTTP/2 (Vercel-Standard) oder Single-Tab-Check helfen.

  3. Broadcast: Der Demo-Code funktioniert auf einer Instanz; auf Vercel brauchen Sie Redis Pub/Sub oder Upstash.

Cross-Instance mit 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 (erweitert)
import { redis } from '@/lib/redis';

export async function GET(request: NextRequest) {
  const stream = new ReadableStream({
    async start(controller) {
      const encoder = new TextEncoder();
      const channelName = 'chat_messages';

      const interval = setInterval(async () => {
        const messages = await redis.lrange(channelName, 0, -1);
        // Nachrichten verarbeiten...
      }, 1000);

      request.signal.addEventListener('abort', () => {
        clearInterval(interval);
        controller.close();
      });
    }
  });

  return new Response(stream, {
    headers: {
      'Content-Type': 'text/event-stream',
      'Cache-Control': 'no-cache'
    }
  });
}

Wann SSE?

Empfehlung:

  • ✅ Live-Benachrichtigungen
  • ✅ Aktienkurse
  • ✅ Live-Logs
  • ✅ Fortschrittsbalken
  • ❌ Hochfrequenter bidirektionaler Chat (WebSocket)
  • ❌ Online-Spiele (WebSocket)

Nachrichtenstatus und Synchronisation

Echtzeit ist mehr als Senden und Empfangen: Wurde die Nachricht gesendet? Gelesen? Geht etwas bei Netzausfall verloren?

Darum habe ich bei Chat-Funktionen tagelang gehangen.

Vier Status (angelehnt an WeChat)

  1. Wird gesendet: Klick auf Senden, noch keine Server-Antwort
  2. Gesendet: Server hat empfangen, Empfänger evtl. noch nicht
  3. Zugestellt: Client des Empfängers hat empfangen
  4. Fehlgeschlagen: Netz- oder Serverfehler

Einfache State Machine:

// types/message.ts
export type MessageStatus = 'sending' | 'sent' | 'delivered' | 'failed';

export interface Message {
  id: string;
  localId: string;
  user: string;
  content: string;
  timestamp: string;
  status: MessageStatus;
}

Optimistisches Update + Retry

Nicht auf die Server-Antwort warten, bevor die Nachricht erscheint – optimistisches Update mit Status „Wird gesendet“:

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

Reconnect und IndexedDB

Bei instabilem Netz: unbestätigte Nachrichten in IndexedDB, nach Reconnect erneut senden.

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

Praxis-Tipps

  1. Nicht alles auf einmal: erst Senden/Empfangen, dann Status.
  2. Fehlschlag zuerst: Nutzer wollen wissen, ob gesendet – „Gelesen“ ist oft zweitrangig.
  3. IndexedDB: bei schlechtem Netz unverzichtbar.
  4. Offline testen: Chrome DevTools → Network → Offline.

Produktion: Deployment und Performance

Lokal läuft alles – live oft Probleme. Ein paar Punkte für weniger Überraschungen.

Plattformen und Limits

PlattformWebSocketSSEBesonderheiten
VercelEdge: 25 s Timeout; Serverless: 60 s
NetlifyFunctions: 10 s
RailwayKein hartes Timeout, Traffic-basiert
RenderFree-Tier mit Sleep
Cloudflare PagesWorkers CPU-Limit
Eigener VPSSelbst verwaltet

Empfehlung:

  • Knappes Budget, wenig Traffic: Vercel + SSE
  • WebSocket nötig: Railway oder Render ($5–10/Monat)
  • Hohe Last, Budget: VPS + Nginx

SSE auf Vercel optimieren

25-Sekunden-Limit: Auto-Reconnect + 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 verbunden');
      setIsConnected(true);
    };

    eventSource.onerror = () => {
      console.error('SSE-Fehler, Reconnect in 3 s...');
      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 };
}

Multi-Instance mit Redis

Nutzer A auf Instanz 1, Nutzer B auf Instanz 2 – Redis Pub/Sub verbindet sie. Mit Upstash:

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

export async function publishMessage(channel: string, message: any) {
  await redis.lpush(channel, JSON.stringify(message));
  await redis.ltrim(channel, 0, 99);
}

export async function getRecentMessages(channel: string) {
  const messages = await redis.lrange(channel, 0, -1);
  return messages.map((m) => JSON.parse(m as string)).reverse();
}

Performance-Checkliste

  1. Deduplizierung: Set mit Message-IDs gegen Doppel-Rendering.

  2. Virtuelles Scrollen: ab 100+ Nachrichten react-window oder react-virtualized.

  3. Historie lazy laden: nicht alles auf einmal.

  4. Rate Limiting: Client und Server.

let lastSendTime = 0;
const SEND_INTERVAL = 500;

const sendMessage = async (content: string) => {
  const now = Date.now();
  if (now - lastSendTime < SEND_INTERVAL) {
    alert('Zu schnell – bitte kurz warten');
    return;
  }

  lastSendTime = now;
  // Sendelogik...
};
  1. Monitoring: Sentry oder LogRocket für SSE- und Sendefehler.

Kosten

Echtzeit kann teuer werden – Spar-Tipps:

  • SSE statt WebSocket: kein separater WebSocket-Server
  • Batch-Push: Nachrichten pro Sekunde bündeln statt einzeln
  • Upstash: pay-per-request statt eigener Redis-VM
  • CDN für statische Next.js-Assets

Ein 500-Nutzer-Chatraum mit Vercel + Upstash: unter $15/Monat – wenn die Architektur passt.

Fazit

Zurück zum nächtlichen WebSocket-Debakel – mit diesem Wissen hätte ich nicht so lange daran festgehalten.

Echtzeit in Next.js heißt pragmatisch wählen:

  • Vercel? SSE – WebSocket nicht erzwingen
  • Knappes Budget? Einfachste Lösung zuerst
  • UX? Status und Reconnect wichtiger als Technik-Show

Der Code ist übernehmbar – wichtiger sind die Trade-offs. Kein Silberbullet; was zu Ihrem Projekt passt, zählt.

Wenn Sie Echtzeit bauen: hoffentlich weniger Umwege. Fragen gerne in die Kommentare.

Nächste Schritte:

  1. Lokal ein minimales SSE-Beispiel starten
  2. Auf Vercel deployen und testen
  3. WebSocket nur bei Bedarf – Railway oder Self-Hosting

Technische Schuld ist nicht schlimm – schlimm ist Schuld, die Sie von Anfang an nicht brauchen. Viel Erfolg!

FAQ

Warum unterstützt Vercel kein WebSocket?
Vercel nutzt eine Serverless-Architektur – Ihre App läuft in Cloud Functions. Nach Ende einer Anfrage werden Ressourcen sofort freigegeben; eine WebSocket-Langzeitverbindung ist damit nicht möglich.

Drei Lösungswege:
• SSE (Server-Sent Events) statt WebSocket – von Vercel nativ unterstützt
• Eigener Server (VPS) oder Plattform mit WebSocket (Railway, Render)
• Drittanbieter-WebSocket (Pusher, Ably) – teurer ($49–99/Monat)

Für die meisten Szenarien reicht SSE – günstiger und einfacher.
SSE oder WebSocket – was passt besser zu Chat?
Hängt von Plattform und Anforderungen ab:

• SSE: vor allem unidirektionales Push (Echtzeit-Benachrichtigungen, Chat-Empfang), Deployment auf Vercel/Netlify, unter ca. 1000 Nutzern
• WebSocket: hochfrequente bidirektionale Kommunikation (kollaboratives Editing, Online-Spiele), Self-Hosting, niedrige Latenz

Chat funktioniert oft mit SSE (Empfang) + HTTP POST (Senden) – günstiger und einfacher zu deployen. Ein 500-Nutzer-Chatraum mit SSE + Upstash Redis kostete mich unter $15/Monat.
Wie geht man mit dem 25-Sekunden-Timeout auf Vercel um?
Edge Functions auf Vercel haben ein 25-Sekunden-Limit. Lösung: automatisches Reconnect auf dem Client:

• Heartbeat alle 15 Sekunden – Verbindung gilt nicht als idle
• Client erkennt Abbruch über EventSource onerror
• Reconnect nach 3 Sekunden
• Redis speichert Nachrichten – nach Reconnect ungelesene Nachrichten abrufbar

In der Praxis merken Nutzer den Reconnect kaum – ähnlich wie bei einer dauerhaften Verbindung.
Wie synchronisieren mehrere Instanzen Nachrichten?
Vercel skaliert automatisch – Nutzer können auf verschiedenen Instanzen landen. Lösung: Redis Pub/Sub:

• Nutzer A sendet → Instanz 1 schreibt in Redis
• Instanzen 1, 2, 3 pollen Redis auf neue Nachrichten
• Jede Instanz pusht an ihre verbundenen Clients

Upstash (serverloses Redis) nach Anfragen abrechnen – oft günstiger als eigener Redis. Polling-Intervall ca. 1 Sekunde – Balance zwischen Echtzeit und Kosten.
Wie verhindert man Nachrichtenverlust?
Nachrichten können bei Netzproblemen verloren gehen. Vollständige Lösung:

• Optimistisches Update: sofort in der UI anzeigen, Status „Wird gesendet“
• IndexedDB: unbestätigte Nachrichten lokal speichern
• Server-Bestätigung: bei Erfolg Status „Gesendet“
• Nach Reconnect: unbestätigte Nachrichten aus IndexedDB erneut senden
• Retry: bei Fehlschlag Button zum erneuten Senden

Analog zu WeChat: Wird gesendet, Gesendet, Zugestellt, Fehlgeschlagen.
Kann man Socket.io Custom Server auf Vercel deployen?
Nein. Ein Custom Server braucht einen dauerhaft laufenden Node.js-Prozess – Vercel nur Serverless Functions.

Alternativen:
• Plattformen mit WebSocket: Railway (ab $5/Monat), Render (ab $7/Monat)
• Eigener VPS: Vultr, DigitalOcean (ab $5/Monat)
• Hybrid: Next.js auf Vercel, WebSocket-Service separat

Wer unbedingt Vercel nutzt: SSE – deckt die meisten Echtzeit-Anforderungen ab.
Wo liegen Performance-Engpässe bei Echtzeit-Chat?
Typische Engpässe und Gegenmaßnahmen:

• Nachrichtenliste: ab 100+ Einträgen virtuelles Scrollen (react-window)
• Historie: lazy load beim Scrollen nach oben
• Client-Rate-Limit: max. eine Nachricht alle 500 ms
• Server-Rate-Limit: in API-Routes
• Deduplizierung: Set mit empfangenen Message-IDs
• Verbindungslimit: HTTP/1.1 max. 6 Verbindungen pro Domain – HTTP/2 oder Single-Tab-Check

Monitoring: Sentry, LogRocket, Vercel Analytics.

13 Min. Lesezeit · Veröffentlicht am: 7. Jan. 2026 · Aktualisiert am: 14. Juli 2026

Ähnliche Beiträge

Kommentare

Melde dich mit GitHub an, um einen Kommentar zu hinterlassen

Easton BlogEaston Blog