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

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
| Eigenschaft | WebSocket | SSE | Long Polling |
|---|---|---|---|
| Bidirektional | ✅ | ❌ (mit POST) | ✅ |
| Vercel | ❌ | ✅ (mit Timeout) | ✅ |
| Browser | Modern | Modern | Alle |
| Verbindungsaufwand | Niedrig | Niedrig | Hoch |
| Komplexität | Mittel | Einfach | Sehr einfach |
| Szenario | Chat, Spiele, Kollaboration | Benachrichtigungen, Live-Updates | Seltene 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:
- SSE: unidirektionales Push (Benachrichtigungen, Live-Updates, Chat-Empfang)
- Long Polling: seltene bidirektionale Kommunikation
- 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
-
Hot Reload: Beim Speichern bricht die Socket-Verbindung ab – Nebenwirkung von Next.js, kaum vermeidbar.
-
CORS: Bei unterschiedlichen Ports
corsin der Socket.io-Konfiguration setzen. -
TypeScript:
@types/socket.io-clientinstallieren. -
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:
-
25-Sekunden-Timeout auf Vercel: Edge Functions werden getrennt – Client erkennt Abbruch und verbindet neu.
-
Verbindungslimit: max. 6 HTTP/1.1-Verbindungen pro Domain – mehrere Tabs können hängen. HTTP/2 (Vercel-Standard) oder Single-Tab-Check helfen.
-
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)
- Wird gesendet: Klick auf Senden, noch keine Server-Antwort
- Gesendet: Server hat empfangen, Empfänger evtl. noch nicht
- Zugestellt: Client des Empfängers hat empfangen
- 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
- Nicht alles auf einmal: erst Senden/Empfangen, dann Status.
- Fehlschlag zuerst: Nutzer wollen wissen, ob gesendet – „Gelesen“ ist oft zweitrangig.
- IndexedDB: bei schlechtem Netz unverzichtbar.
- 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
| Plattform | WebSocket | SSE | Besonderheiten |
|---|---|---|---|
| Vercel | ❌ | ✅ | Edge: 25 s Timeout; Serverless: 60 s |
| Netlify | ❌ | ✅ | Functions: 10 s |
| Railway | ✅ | ✅ | Kein hartes Timeout, Traffic-basiert |
| Render | ✅ | ✅ | Free-Tier mit Sleep |
| Cloudflare Pages | ❌ | ✅ | Workers CPU-Limit |
| Eigener VPS | ✅ | ✅ | Selbst 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
-
Deduplizierung:
Setmit Message-IDs gegen Doppel-Rendering. -
Virtuelles Scrollen: ab 100+ Nachrichten
react-windowoderreact-virtualized. -
Historie lazy laden: nicht alles auf einmal.
-
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...
};
- 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:
- Lokal ein minimales SSE-Beispiel starten
- Auf Vercel deployen und testen
- 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?
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?
• 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?
• 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?
• 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?
• 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?
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?
• 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
Next.js Komplettleitfaden
Wenn du über die Suche hier gelandet bist, kommst du am schnellsten weiter, indem du zum vorherigen oder nächsten Beitrag dieser Serie springst.
Vorheriger
Next.js API-Authentifizierung und Sicherheit: Praxisleitfaden von JWT bis Rate Limiting
Vollständiger Praxisleitfaden zur Next.js API-Sicherheit: JWT-Authentifizierung, CORS-Konfiguration, Rate Limiting und Eingabevalidierung – Schritt für Schritt zu einer produktionsreifen, sicheren Anwendung inklusive aktueller Methoden zur Abwehr von Sicherheitslücken.
Teil 20 von 51
Nächster
Next.js Datenbank-Auswahl: PostgreSQL, MySQL, MongoDB und Cloud-Dienste im Vergleich
Welche Datenbank passt zu Ihrem Next.js-Projekt? Vergleich von PostgreSQL, MySQL und MongoDB sowie Vercel Postgres, Supabase, PlanetScale und MongoDB Atlas – drei Fragen für eine schnelle Entscheidung ohne teure Fehlgriffe.
Teil 22 von 51
Ähnliche Beiträge
Next.js App Router Einstieg: Kernkonzepte und Grundlagen im Überblick

Next.js App Router Einstieg: Kernkonzepte und Grundlagen im Überblick
Next.js 15 in der Praxis: Wie ich an einem Wochenende ein produktionsreifes Blog-System aufgebaut habe


Kommentare
Melde dich mit GitHub an, um einen Kommentar zu hinterlassen