Server-Sent Events vs. WebSockets vs. Polling in Next.js 15: Eine Echtzeit-Entscheidungsmatrix für 2026

#Next.js 15 SSE
Sandor Farkas - Founder & Lead Developer at Wolf-Tech

Sandor Farkas

Gründer & Lead Developer

Experte für Softwareentwicklung und Legacy-Code-Optimierung

Du bist im dritten Sprint des Notifications-Features. Der Product Manager will Live-Counter auf dem Dashboard. Das Mobile-Team will Presence-Indikatoren. Jemand hat ein Ticket geöffnet, das fragt, warum der Activity-Feed immer noch einen Seitenrefresh erfordert. Du hast drei Primitive zur Verfügung - Server-Sent Events, WebSockets und Polling - und das Internet streitet seit 2011 darüber, welches man verwenden soll. Der Haken ist, dass Next.js 15 mit dem App Router und React Server Components mehrere Annahmen verändert, die diese Debatte angetrieben haben.

Das hier ist die praktische Aufschlüsselung: was jedes Primitiv unter realer Last kostet, wo es sich sauber mit Next.js 15 kombiniert und wo es gegen das Framework kämpft, und eine Entscheidungsmatrix, die an den Feature-Typ geknüpft ist, nicht an architektonische Ideologie.

Wie Next.js 15 die Kalkulation verändert

Das React-Server-Components-Modell in Next.js 15 macht SSE zu einem deutlich attraktiveren Primitiv als in der Pages-Router-Ära. RSC-Streaming verwendet bereits eine persistente HTTP/2-Verbindung vom Server zum Client - SSE für Anwendungs-Events hinzuzufügen passt natürlich auf dasselbe mentale Modell. Die ReadableStream-API, die in Next.js-Route-Handlern verfügbar ist, ermöglicht das Pushen inkrementeller Daten über eine Standard-HTTP-Antwort ohne einen separaten Serverprozess.

WebSockets hingegen sitzen unkomfortabel im App Router. Die Route Handler des App Routers laufen standardmäßig in Node.js und können WebSocket-Upgrades handhaben, aber die Edge-Runtime unterstützt sie nicht. Wenn du auf Vercel deployest und Edge Functions für Performance nutzen willst, sind WebSockets schlicht nicht verfügbar. Du brauchst einen dedizierten WebSocket-Server - oft einen separaten Node.js-Prozess, einen Serverless-Service wie Ably oder Pusher oder einen Adapter wie Socket.io, der außerhalb des Next.js-Prozesses läuft.

Polling erhielt während der RSC-Ära überraschend wenig Innovation, bleibt aber in mehr Situationen die richtige Wahl, als sein Ruf vermuten lässt.

Die drei Ansätze auf einem realistischen SaaS-Dashboard benchmarken

Um das konkret zu machen, betrachte ein B2B-SaaS-Dashboard mit drei Echtzeit-Anforderungen: ein Notification-Badge-Counter, der sich aktualisiert, wenn neue Einträge ankommen, eine Presence-Liste, die zeigt, welche Teammitglieder aktuell aktiv sind, und ein Live-Activity-Feed mit neuen Zeilen, die bei Events angehängt werden. Alle drei Features befinden sich auf einer einzigen Dashboard-Ansicht mit authentifizierten Nutzern. Benchmark-Umgebung: Next.js 15.1 auf Vercel (edge-proxied Node-Funktionen) und einem selbst gehosteten DigitalOcean-Droplet (Node 22, 4 vCPU, 8 GB RAM).

Server-Sent Events: Benchmarks und Charakteristika

Jede SSE-Verbindung ist eine persistente HTTP-Verbindung. Bei 1.000 gleichzeitigen Nutzern, die je eine SSE-Verbindung offen halten, wird der Node.js-File-Descriptor-Verbrauch sichtbar - du schaust auf 1.000 offene Verbindungen, die je ca. 50-80 KB Speicher in Nodes Net-Layer verbrauchen. Auf einer selbst gehosteten 8-GB-Node-Instanz verbrauchen 1.000 gleichzeitige SSE-Verbindungen ca. 100-150 MB, was ausreichend Spielraum lässt. Bei 5.000 gleichzeitigen Verbindungen verlagert sich die Einschränkung zur Event-Loop-Verarbeitung - jeder write()-Aufruf an einen ReadableStream-Controller hat ca. 0,3 ms Overhead pro Event in Node 22.

Auf Vercel streamt SSE via Route Handlers problemlos, unterliegt aber einem Standard-Timeout von 30 Sekunden bei der Funktionsausführung. Für lang laufende Notification-Streams musst du export const maxDuration = 300 im Route Handler setzen und sicherstellen, dass dein Plan das unterstützt. Jeder Vercel-Funktionsaufruf, der eine SSE-Verbindung hält, zählt zu deinem Limit für gleichzeitige Funktionsaufrufe.

// app/api/events/route.ts
export const runtime = 'nodejs'; // Edge-Runtime unterstützt SSE NICHT
export const maxDuration = 300;

export async function GET(request: Request): Promise<Response> {
  const { searchParams } = new URL(request.url);
  const userId = searchParams.get('userId');

  const stream = new ReadableStream({
    start(controller) {
      const encoder = new TextEncoder();

      const send = (event: string, data: unknown) => {
        controller.enqueue(
          encoder.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`)
        );
      };

      // Eventquelle abonnieren (Redis pub/sub, Postgres LISTEN, etc.)
      const unsubscribe = eventBus.subscribe(userId, (event) => {
        send(event.type, event.payload);
      });

      // Keepalive, um Proxy-Timeouts zu verhindern
      const keepalive = setInterval(() => {
        controller.enqueue(encoder.encode(': keepalive\n\n'));
      }, 25_000);

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

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

Der client-seitige Verbrauch mit React-Hooks ist unkompliziert:

// hooks/useServerEvents.ts
import { useEffect } from 'react';

export function useServerEvents(userId: string, onEvent: (type: string, data: unknown) => void) {
  useEffect(() => {
    const es = new EventSource(`/api/events?userId=${userId}`);

    es.addEventListener('notification', (e) => onEvent('notification', JSON.parse(e.data)));
    es.addEventListener('presence', (e) => onEvent('presence', JSON.parse(e.data)));

    es.onerror = () => {
      // EventSource reconnectet automatisch mit exponentiellem Backoff
    };

    return () => es.close();
  }, [userId]);
}

SSE-Kosten auf Vercel: Jede gehaltene Verbindung ist ein laufender Funktionsaufruf. Bei 500 täglich aktiven Nutzern, die je eine Verbindung für eine typische 45-minütige Session halten, ergibt das ca. 22.500 Funktions-Minuten pro Tag. Im Vercel-Pro-Plan sind 1.000 GB-Stunden Funktionsausführung pro Monat inkludiert - 22.500 Verbindungsminuten bei der Standard-512-MB-Speicherzuweisung sind ca. 188 GB-Stunden pro Tag, was bei Skalierung die inkludierten Kontingente erschöpfen kann. Auf selbst gehosteter Infrastruktur sind die inkrementellen Kosten von SSE nach der Basisserver-Kapazität effektiv null.

WebSockets: Benchmarks und Charakteristika

WebSockets eliminieren den HTTP-Overhead von SSEs persistenter Chunked-Transfer-Verbindung und unterstützen Full-Duplex-Kommunikation. Der praktische Performance-Unterschied bei einem typischen Notification- oder Presence-Use-Case ist marginal - weder SSE noch WebSockets kommen bei Text-Payloads in die Nähe der Sättigung moderner Netzwerkinterfaces. Der echte operative Unterschied liegt in der Deployment-Topologie.

Ein Next.js-Route-Handler kann kein WebSocket-Upgrade auf der Edge-Runtime durchführen. Auf der Node.js-Runtime kannst du die @socket.io/next-Integration verwenden oder den rohen Upgrade handhaben:

// Funktioniert NUR mit einem Custom Server - NICHT mit standardmäßigem next start
// server.ts (Custom Next.js Server)
import { createServer } from 'http';
import { Server as SocketIOServer } from 'socket.io';
import next from 'next';

const app = next({ dev: process.env.NODE_ENV !== 'production' });
const handler = app.getRequestHandler();

app.prepare().then(() => {
  const httpServer = createServer(handler);
  const io = new SocketIOServer(httpServer);

  io.on('connection', (socket) => {
    const userId = socket.handshake.auth.userId;
    socket.join(`user:${userId}`);

    socket.on('disconnect', () => {
      // Aufräumen
    });
  });

  httpServer.listen(3000);
});

Die wichtige Einschränkung: Ein Custom Server deaktiviert die automatische statische Optimierung und bestimmte Vercel-Deployment-Features. Die meisten Teams, die WebSockets in einer Vercel-deployed-Next.js-App brauchen, leiten WebSocket-Traffic an einen separaten Service weiter (Ably, Pusher, Liveblocks oder einen dedizierten Node-Service) und halten Next.js selbst zustandslos.

WebSocket-Kosten auf selbst gehostet: Speicher-Overhead pro WebSocket-Verbindung in Node.js mit Socket.io ist ca. 60-90 KB nach dem Handshake. Bei 5.000 gleichzeitigen Verbindungen auf einer 4-vCPU-/8-GB-Maschine kannst du mit 400-500 MB rechnen, die vom Socket.io-Layer verbraucht werden, was komfortablen Spielraum lässt. Ab 20.000+ gleichzeitigen Verbindungen wird horizontale Skalierung mit einem Redis-Adapter für den Socket.io-Pub/Sub-Layer notwendig.

WebSockets auf Vercel / Serverless: Nicht praktikabel ohne einen separaten WebSocket-Service. Budgetiere 20-150 $/Monat für verwaltete WebSocket-Services auf früher SaaS-Skalierung, abhängig vom Nachrichten-Volumen. Der Break-Even gegenüber einem selbst gehosteten separaten Node-WebSocket-Server hängt stark von deiner DevOps-Kapazität ab.

Polling: Immer noch öfter die richtige Antwort als gedacht

Short-Poll (Client sendet eine Anfrage in festem Intervall) und Long-Poll (Client hält eine Anfrage offen, bis der Server Daten oder einen Timeout hat) werden als veraltete Muster abgetan, haben aber einen überzeugenden Vorteil: sie funktionieren überall ohne persistente Verbindungen, Reconnection-Logik oder Deployment-Topologie-Einschränkungen.

Für Features, bei denen sich Daten seltener als einmal pro zehn Sekunden ändern - Audit-Logs, Report-Status, Hintergrundjob-Fortschritt nach der ersten Übermittlung - ist Polling operativ einfacher und leichter zu cachen:

// app/api/job-status/[id]/route.ts
export async function GET(
  _req: Request,
  { params }: { params: { id: string } }
): Promise<Response> {
  const job = await jobRepository.findById(params.id);

  return Response.json(
    { status: job.status, progress: job.progress },
    {
      headers: {
        // Aggressives Caching für Endzustände
        'Cache-Control': job.isTerminal
          ? 'public, max-age=3600'
          : 'no-store',
      },
    }
  );
}
// Client: Polling mit exponentiellem Backoff
async function pollJobStatus(jobId: string, onUpdate: (status: JobStatus) => void) {
  let interval = 2_000;
  const MAX_INTERVAL = 30_000;

  const poll = async () => {
    const res = await fetch(`/api/job-status/${jobId}`);
    const data = await res.json();
    onUpdate(data);

    if (!data.isTerminal) {
      setTimeout(poll, interval);
      interval = Math.min(interval * 1.5, MAX_INTERVAL);
    }
  };

  poll();
}

Auf Vercel ist ein Short-Polling-Ansatz mit korrekten Cache-Control-Headern bei Skalierung wirklich günstig, weil Vercels Edge-Caching viele Anfragen abfängt, bevor sie jemals deine Funktion erreichen. Ein Notification-Badge, der alle 30 Sekunden mit einem 10-Sekunden-stale-while-revalidate-Cache pollt, wird deine Funktion nur einmal pro 10 Sekunden pro eindeutiger Nutzer-ID treffen - der Cache absorbiert alles dazwischen.

Load-Balancer- und Edge-Runtime-Fallstricke

Mehrere Fehlerszenarien tauchen bei der Überprüfung von Echtzeit-Architekturen wiederholt auf.

SSE und Load-Balancer-Timeouts. Application-Load-Balancer (AWS ALB, GCP HTTPS LB, nginx-Standardkonfiguration) schließen idle Verbindungen nach 60 Sekunden, wenn nicht anders konfiguriert. Eine SSE-Verbindung, die seltene Events sendet, wird stillschweigend vom Load-Balancer getrennt, während die Client-EventSource-API annimmt, die Verbindung sei aktiv. Die Lösung: Sende alle 25 Sekunden einen Keepalive-Kommentar (: keepalive) vom Server und konfiguriere das Idle-Timeout deines Load-Balancers passend zu deiner maxDuration. Das obige Code-Beispiel enthält dieses Muster.

WebSockets und Sticky Sessions. Socket.io mit mehreren Node-Prozessen erfordert entweder einen Redis-Adapter für Pub/Sub-Fanout oder Sticky-Load-Balancing (der gleiche Client wird immer an denselben Server geroutet). Ohne eines davon erreicht ein Event, das an einen Nutzer gesendet wird, dessen Verbindung auf Server B liegt, diesen nie, wenn der Emitter auf Server A ist. Das ist einer der häufigsten Architektur-Fehler in horizontal skalierten WebSocket-Deployments.

SSE und die Edge-Runtime. Einen Route Handler mit export const runtime = 'edge' zu markieren deaktiviert ReadableStream-Keepalive-Semantik und verursacht Verbindungsabbrüche auf einigen Cloudflare-Workers-Deployments. SSE muss auf runtime = 'nodejs' laufen. Das ist eine nicht offensichtliche Einschränkung, die nach dem Deployment Stunden verschwendet, wenn sie entdeckt wird.

Reconnection-Stürme. Wenn ein Server neu startet, reconnecten alle SSE-Clients gleichzeitig. Bei 5.000 gleichzeitigen Nutzern erzeugt ein Rolling-Deployment, das einen Pod neustartet, eine Thundering-Herd, die kurzzeitig deinen Event-Bus-Subscription-Handler sättigen kann. Begrenze Reconnection-Versuche server-seitig und implementiere Jitter in deiner client-seitigen Reconnection-Verzögerung.

Die Entscheidungsmatrix

Feature-TypAktualisierungsfrequenzVercelSelbst gehostetEmpfehlung
Notification-BadgeNutzergesteuert, < 1/Min.Polling (30s + stale-while-revalidate)SSE oder PollingPolling
Presence-IndikatorenKontinuierlich, < 5s AktualitätVerwalteter WebSocket-ServiceSSESSE (selbst gehostet), verwaltetes WS (Vercel)
Live-Activity-FeedKontinuierlich, nur AnhängenSSE (maxDuration-Kosten beachten)SSESSE
Kollaboratives BearbeitenKontinuierlich, bidirektionalVerwalteter WebSocket-ServiceWebSocketsWebSockets
Hintergrundjob-FortschrittInitiiert, EndzustandPolling mit exponentiellem BackoffPollingPolling
Chat / MehrspielerHochfrequent, bidirektionalVerwalteter WebSocket-ServiceWebSocketsWebSockets

Die Faustregel: Wenn dein Echtzeit-Feature unidirektional ist (Server pusht zum Client), ist SSE das richtige Primitiv für selbst gehostete Deployments und auf Vercel bis zu einigen Hundert gleichzeitigen Nutzern meist die kosteneffizienteste Option. Wenn dein Feature erfordert, dass der Client häufig oder in Echtzeit Daten sendet, sind WebSockets den operativen Aufwand wert. Wenn sich deine Daten seltener als einmal alle 10-30 Sekunden ändern, schlägt Polling mit korrekter Cache-Semantik beide.

Das auf deinen Code anwenden

Die Architekturentscheidung ist am wichtigsten auf der Infrastrukturebene - die Wahl zwischen SSE und WebSockets ist eine Entscheidung, mit der du jahrelang lebst. Teams treffen sie oft unter Zeitdruck, ohne ihre tatsächlichen Feature-Anforderungen gegen ihre Deployment-Umgebung zu benchmarken, und entdecken dann die Fehlanpassung, wenn der Traffic wächst.

Wenn deine Echtzeit-Architektur bereits besteht und du vermutest, dass sie nicht die richtige ist, ist ein Code-Qualitäts-Audit ein strukturierter Weg, sie zusammen mit dem Rest des Systems zu bewerten. Wir finden regelmäßig Teams, die Socket.io für Features betreiben, die SSE sauber zu einem Fünftel der Infrastrukturkosten handhabt, und umgekehrt - SSE-Implementierungen, die für bidirektionale Use-Cases nachgerüstet wurden, bei denen die Workarounds lastragend geworden sind.

Melde dich unter hello@wolf-tech.io oder besuche wolf-tech.io, wenn du deine Optionen evaluierst oder eine unabhängige Einschätzung möchtest, bevor du dich auf einen Ansatz festlegst.