byteNative
Entwicklung

Socket.IO horizontal skalieren, ohne dass Nachrichten verschwinden

Ein Socket.IO-Server läuft. Dann kommt die zweite Instanz dazu, und plötzlich hängen Verbindungen, Nachrichten erreichen nur einen Teil der Nutzer. Das sind zwei getrennte Probleme, und man braucht beide Lösungen.

10 Min. LesezeitStand März 2026

Es sind zwei Probleme, nicht eins

Sobald mehr als eine Instanz läuft, gehen zwei Dinge kaputt, die gern verwechselt werden. Das erste betrifft den Verbindungsaufbau, das zweite die Verteilung der Nachrichten. Wer nur eines löst, sucht danach lange nach Gespenstern.

Problem eins: Socket.IO baut eine Verbindung nicht in einem Schritt auf. Es beginnt mit HTTP-Polling und rüstet danach auf WebSocket hoch. Diese Anfragen gehören zusammen und müssen bei derselben Instanz landen. Verteilt dein Load Balancer sie im Wechsel, kennt Instanz B die Session nicht, die Instanz A gerade eröffnet hat, und der Handshake bricht ab.

Problem zwei: Jede Instanz kennt nur ihre eigenen Verbindungen. Ein `io.emit` auf Instanz A erreicht die Clients auf Instanz B nie. Das fällt im Test mit zwei Browsern oft nicht auf, weil beide zufällig auf derselben Instanz landen, und im Betrieb sofort.

Problem eins: Sticky Sessions

Der Load Balancer muss einen Client dauerhaft derselben Instanz zuordnen. Bei Nginx erledigt das `ip_hash`, oder sauberer der `sticky`-Mechanismus, falls verfügbar. Wichtig sind daneben die Header für das Protokoll-Upgrade und ein Timeout, das lange offene Verbindungen nicht nach einer Minute kappt.

Der zweite Teil wird oft vergessen: Das Standard-Timeout von Nginx liegt bei 60 Sekunden. Eine WebSocket-Verbindung ist per Definition lange offen und still. Ohne höheres `proxy_read_timeout` trennt dir der Proxy die Verbindung ständig, und du suchst den Fehler in der Anwendung.

nginx
upstream realtime {
    # gleiche client-ip landet immer auf derselben instanz
    ip_hash;
    server app-1:3000;
    server app-2:3000;
}

server {
    listen 443 ssl;
    server_name realtime.example.com;

    location /socket.io/ {
        proxy_pass http://realtime;
        proxy_http_version 1.1;

        # ohne diese beiden header kommt kein websocket-upgrade zustande
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;

        # default sind 60s, das killt stille verbindungen
        proxy_read_timeout 3600s;
        proxy_send_timeout 3600s;
    }
}
ip_hash ist die einfachste Variante. Sitzen viele Nutzer hinter derselben NAT-IP, verteilt sie ungleich; dann lohnt ein cookie-basiertes Sticky-Verfahren.

Problem zwei: der Redis-Adapter

Der Adapter hängt alle Instanzen an einen gemeinsamen Redis und leitet Events zwischen ihnen weiter. Ein `emit` auf Instanz A geht über Redis an alle anderen, die es an ihre eigenen Clients zustellen. Für den Anwendungscode ändert sich nichts, du schreibst weiter `io.to(room).emit(...)`.

Zwei Details entscheiden über Erfolg. Du brauchst zwei Redis-Clients, einen zum Veröffentlichen und einen zum Abonnieren, weil ein Client im Subscribe-Modus keine anderen Befehle mehr annimmt. Und der Adapter muss stehen, bevor die erste Verbindung angenommen wird.

ts
import { createAdapter } from "@socket.io/redis-adapter";
import { createClient } from "redis";
import { Server } from "socket.io";

const io = new Server(httpServer, {
    cors: { origin: process.env.APP_ORIGIN },
});

// zwei verbindungen: eine im subscribe-modus nimmt keine befehle mehr an
const pubClient = createClient({ url: process.env.APP_REDIS_URL });
const subClient = pubClient.duplicate();

await Promise.all([pubClient.connect(), subClient.connect()]);
io.adapter(createAdapter(pubClient, subClient));

// ab hier verhaelt sich der cluster wie eine einzelne instanz
io.on("connection", (socket) => {
    socket.join("webinar-42");
    io.to("webinar-42").emit("viewer:joined", { id: socket.id });
});

httpServer.listen(3000);
Der Adapter wird gesetzt, bevor der Server lauscht. Danach erreicht io.to() alle Instanzen.

Räume, Broadcasts und was davon teuer wird

Räume sind in Socket.IO billig, solange du sie zum Zustellen benutzt. Teuer wird es, wenn du sie als Datenbank missbrauchst. Die Mitgliederliste eines Raums über den Cluster hinweg abzufragen, kostet eine Rundreise zu allen Instanzen, und das bei jedem Aufruf.

Für Anzeigen wie „gerade online“ führst du besser einen Zähler in Redis und schreibst ihn beim Betreten und Verlassen fort. Das ist eine Operation statt einer Sammelabfrage, und der Wert stimmt auch dann, wenn eine Instanz neu startet.

Ähnlich bei Broadcasts an alle: Ein `io.emit` an Zehntausende Verbindungen ist kein einzelnes Ereignis, sondern Zehntausende Zustellungen, verteilt über alle Instanzen. Wenn so etwas häufig passiert, etwa bei einem Zähler, der jede Sekunde aktualisiert, drossle serverseitig und schick gebündelte Aktualisierungen alle paar Sekunden statt einzelner.

Bevor du live gehst

Die Fehler, die uns im Betrieb begegnet sind, hätte man fast alle vorher finden können. Diese Punkte gehören auf die Liste.

  • Mit mindestens zwei Instanzen testen, nie gegen eine einzelne. Lokal geht das über Docker Compose mit einem Nginx davor.
  • Eine Instanz im laufenden Betrieb hart abschießen und schauen, ob die Clients sauber neu verbinden und ihre Räume wieder betreten.
  • Beim Wiederverbinden Räume erneut beitreten. Der Client weiß nach einem Reconnect nichts mehr von seinen alten Räumen.
  • Redis abschalten und prüfen, ob das in eurem Monitoring sichtbar wird, statt still zu scheitern.
  • Die Timeouts aller Schichten abgleichen: Client, Socket.IO, Nginx und ein etwaiger Cloud-Load-Balancer davor.

Häufige Fragen

Brauche ich Sticky Sessions auch mit dem Redis-Adapter?

Ja, das sind zwei verschiedene Baustellen. Der Adapter verteilt Nachrichten zwischen Instanzen, er repariert aber keinen abgebrochenen Handshake. Nur wenn du reines WebSocket ohne Polling-Fallback erzwingst, kannst du auf Sticky Sessions verzichten, und dann verlierst du Clients in restriktiven Netzen.

Geht das auch ohne Redis?

Es gibt Adapter für Postgres, MongoDB und NATS, und für zwei Instanzen tut es auch der Cluster-Adapter innerhalb eines Hosts. Redis ist trotzdem der übliche Weg, weil es genau für dieses Zustellmuster gebaut ist und kaum Latenz kostet.

Wie viele Verbindungen hält eine Instanz?

Als grober Anhaltspunkt sind einige Tausend gleichzeitige Verbindungen pro Node-Prozess realistisch, wenn die Nachrichtenrate moderat bleibt. Die Grenze ist selten die Verbindungszahl selbst, sondern was ihr pro Nachricht an Arbeit macht.

Ist Socket.IO überhaupt noch die richtige Wahl?

Wenn du Räume, automatisches Wiederverbinden und einen Fallback für schwierige Netze brauchst, ja. Wenn du nur einen einfachen Stream in eine Richtung schickst, ist ein nacktes WebSocket oder Server-Sent Events schlanker und macht weniger Annahmen.

Dieser Beitrag gibt den Stand von März 2026 wieder und ersetzt keine Rechtsberatung. Für verbindliche Auskünfte zu deinem konkreten Fall wende dich an eine Anwältin oder einen Anwalt.

Unsicher, wo deine Seite steht?

Wir schauen einmal drüber und sagen dir ehrlich, was zu tun ist. Das Erstgespräch kostet dich nichts.