Server-Sent Events: Live-Updates ohne Polling in Alpine.js-Komponenten
AI generated
x-data
Alpine
Alpine.js / Echtzeit
Server-Sent Events: Live-Updates ohne Polling
Wie eine EventSource-Verbindung Server-Push-Daten direkt in eine Alpine-Komponente streamt

Wiederholtes Nachfragen per setInterval ist der naheliegendste Weg, um eine Alpine-Komponente aktuell zu halten, aber es ist selten der effizienteste. Server-Sent Events bieten dafür eine schlanke Alternative: Der Browser öffnet über die native EventSource-API eine einzige, dauerhafte HTTP-Verbindung, und der Server schickt neue Daten genau dann, wenn sich tatsächlich etwas ändert, statt auf eine Anfrage zu warten. Dieser Artikel zeigt, wie eine solche Verbindung sauber in x-data initialisiert wird, wie eingehende Events in reaktiven Alpine-State übersetzt werden, wie sich Verbindungsabbrüche zuverlässig abfangen lassen und wo die technischen Grenzen von Server-Sent Events gegenüber Polling und WebSocket liegen.

10 Min. Lesezeit EventSource Reconnect-Handling

1. Warum wiederholtes Nachfragen an seine Grenzen stößt

Ein Polling-Intervall von fünf Sekunden klingt nach einer harmlosen Entscheidung, erzeugt aber bei tausend gleichzeitig geöffneten Tabs zwölftausend Requests pro Minute, obwohl sich der zugrunde liegende Wert oft minutenlang gar nicht ändert. Jede dieser Anfragen durchläuft den vollständigen HTTP-Stack samt TLS-Handshake bei fehlender Connection-Reuse, Routing, Anwendungslogik und Datenbankabfrage, selbst wenn die Antwort am Ende identisch mit der vorherigen ist. Das ist Rechenleistung, die für keine tatsächliche Änderung aufgewendet wird.

Hinzu kommt ein Aktualitätsproblem: Zwischen zwei Polling-Zyklen liegt immer ein Fenster, in dem der Client veraltete Daten anzeigt, ohne es zu wissen. Bei einem Lagerbestand, der sich während eines Sale-Events im Sekundentakt ändert, bedeutet ein Fünf-Sekunden-Intervall im schlechtesten Fall eine fast fünf Sekunden alte Anzeige, während gleichzeitig unnötige Last erzeugt wird, wenn sich nichts geändert hat. Server-Sent Events lösen genau dieses Dilemma, indem sie die Initiative dem Server überlassen.

2. Was Server-Sent Events technisch ausmacht

Server-Sent Events basieren auf einer einzigen, lange offen gehaltenen HTTP-Verbindung mit dem Antwort-Content-Type text/event-stream. Der Server schreibt in diesen Stream fortlaufend Textblöcke im SSE-Format, jeweils mit einem data-Feld und optional einem event-Namen sowie einer id, und die Verbindung bleibt bestehen, bis Client oder Server sie aktiv beenden. Anders als bei WebSocket findet kein separater Protokoll-Handshake mit Upgrade auf ws:// statt, SSE läuft vollständig über normales HTTP und lässt sich dadurch auch von bestehender HTTP-Infrastruktur wie Load-Balancern in den meisten Fällen ohne Sonderbehandlung durchreichen.

Der entscheidende Unterschied zu Polling liegt in der Kommunikationsrichtung: Bei SSE initiiert ausschließlich der Server den Datenversand, der Client öffnet die Verbindung einmal und empfängt danach passiv, was ankommt. Das spart nicht nur wiederholte Requests, sondern bringt auch ein automatisches Reconnect-Verhalten mit, das direkt in der Spezifikation verankert ist und nicht erst von Hand nachgebaut werden muss, wie es bei einer selbstgebauten WebSocket-Lösung nötig wäre.

3. Eine EventSource-Verbindung in x-data initialisieren

In einer Alpine-Komponente gehört die EventSource-Initialisierung in die init()-Methode, damit die Verbindung genau dann aufgebaut wird, wenn das Element in den DOM eingehängt wird, und nicht schon beim reinen Auswerten des x-data-Ausdrucks. Der EventSource-Konstruktor nimmt die URL des Streams entgegen und öffnet die Verbindung automatisch, ganz ohne zusätzlichen connect()-Aufruf.

Wichtig ist, die Instanz auf this zu speichern, damit sie später im $destroy-Hook wieder erreichbar ist. Ohne diese Referenz bleibt die Verbindung offen, selbst wenn die Komponente längst aus dem DOM entfernt wurde, was auf Dauer zu einer wachsenden Zahl verwaister Verbindungen im Browser führt.


document.addEventListener('alpine:init', () => {
  Alpine.data('stockWidget', (productSku) => ({
    stock: null,
    connected: false,
    lastUpdate: null,

    init() {
      this.source = new EventSource(`/sse/stock/${productSku}`);

      this.source.onopen = () => {
        this.connected = true;
      };

      this.source.onmessage = (event) => {
        this.applyUpdate(event.data);
      };

      this.source.onerror = () => {
        // readyState 0 = CONNECTING (Browser versucht bereits automatisch neu)
        // readyState 2 = CLOSED (Verbindung endgültig beendet)
        this.connected = this.source.readyState !== EventSource.CLOSED;
      };
    },

    applyUpdate(rawData) {
      const payload = JSON.parse(rawData);
      this.stock = payload.quantity;
      this.lastUpdate = new Date();
    },

    destroy() {
      this.source?.close();
    },
  }));
});

4. Empfangene Events parsen und reaktiven State aktualisieren

Standardmäßig löst jeder data-Block im Stream das generische message-Event aus, das über onmessage abgefangen wird. Sobald ein Stream aber mehrere Arten von Ereignissen transportiert, etwa Lagerbestand-Änderungen und Preis-Änderungen über denselben Kanal, lohnt sich der Wechsel auf benannte Events mit addEventListener, weil sich die Verarbeitung dann sauber pro Ereignistyp trennen lässt, ohne den rohen Payload manuell auf ein type-Feld zu prüfen.

Da EventSource ausschließlich Text überträgt, muss jeder komplexere Payload serverseitig als JSON serialisiert und clientseitig mit JSON.parse wieder in ein Objekt umgewandelt werden. Die eigentliche Reaktivität übernimmt Alpine danach automatisch: Sobald eine Property wie this.stock innerhalb der Komponente neu zugewiesen wird, aktualisiert sich jedes gebundene x-text oder x-show im Template ohne weiteres Zutun.


init() {
  this.source = new EventSource(`/sse/stock/${this.productSku}`);

  this.source.addEventListener('stock-changed', (event) => {
    const payload = JSON.parse(event.data);
    this.stock = payload.quantity;
    this.lowStock = payload.quantity > 0 && payload.quantity <= 5;
  });

  this.source.addEventListener('price-changed', (event) => {
    const payload = JSON.parse(event.data);
    this.price = payload.formattedPrice;
    this.$dispatch('price-updated', { sku: this.productSku });
  });
}

5. Reconnect-Verhalten: was der Browser automatisch übernimmt und was nicht

Ein großer praktischer Vorteil von Server-Sent Events ist, dass der Browser bei einem Verbindungsabbruch von sich aus einen Reconnect versucht, standardmäßig nach etwa drei Sekunden. Der Server kann dieses Intervall über eine retry-Zeile im Stream beeinflussen, und über den Last-Event-ID-Header meldet der Client bei jedem Reconnect automatisch, welches Event er zuletzt empfangen hat, sodass ein entsprechend implementierter Server nahtlos an dieser Stelle fortsetzen kann.

Dieses automatische Verhalten greift jedoch nicht in jedem Fall: Ruft der Server explizit source.close() im Client auf oder schickt er einen HTTP-Statuscode außerhalb des Erfolgsbereichs, wechselt readyState endgültig auf CLOSED, und der Browser versucht keinen weiteren Reconnect mehr. Für diesen Fall lohnt sich ein eigener Wiederherstellungsmechanismus in der Komponente, der den readyState periodisch prüft und bei Bedarf eine komplett neue EventSource-Instanz mit exponentiell steigender Wartezeit erstellt, statt die Nutzeroberfläche stillschweigend ohne Live-Daten zurückzulassen.


reconnectWithBackoff(attempt = 1) {
  const delay = Math.min(1000 * 2 ** attempt, 30000);

  setTimeout(() => {
    if (this.source?.readyState === EventSource.CLOSED) {
      this.source = new EventSource(`/sse/stock/${this.productSku}`);
      this.bindHandlers();
      this.source.onerror = () => this.reconnectWithBackoff(attempt + 1);
    }
  }, delay);
}

6. Serverseitige Voraussetzungen für einen SSE-Stream

Der Server muss die Antwort mit dem Header Content-Type: text/event-stream ausliefern, Output-Buffering deaktivieren und nach jedem geschriebenen Block explizit flushen, damit die Daten sofort und nicht erst am Ende des PHP-Skripts beim Client ankommen. In einer Symfony-Anwendung übernimmt eine StreamedResponse genau diese Aufgabe, indem der Callback in einer Schleife wartet, neue Daten prüft und bei Bedarf einen SSE-formatierten Block ausgibt.

Zusätzlich empfiehlt sich ein regelmäßiger Kommentar-Heartbeat, also eine Zeile, die mit einem Doppelpunkt beginnt und vom SSE-Client ignoriert wird, damit Reverse-Proxys wie Nginx die Verbindung nicht wegen vermeintlicher Inaktivität nach einem Timeout kappen. Bei Nginx muss außerdem der Header X-Accel-Buffering auf no gesetzt werden, sonst puffert der Proxy die Antwort komplett und liefert sie erst nach Verbindungsende aus, was den gesamten Zweck von Server-Sent Events zunichtemacht.


<?php
declare(strict_types=1);

namespace App\Controller;

use Symfony\Component\HttpFoundation\StreamedResponse;
use Symfony\Component\Routing\Attribute\Route;

final class StockStreamController
{
    #[Route('/sse/stock/{sku}', name: 'stock_stream')]
    public function stream(string $sku, StockChangeReader $reader): StreamedResponse
    {
        $response = new StreamedResponse(function () use ($sku, $reader): void {
            while (true) {
                if (connection_aborted()) {
                    break;
                }

                $change = $reader->pollNextChange($sku);

                if ($change !== null) {
                    echo "event: stock-changed\n";
                    echo 'data: ' . json_encode($change) . "\n\n";
                } else {
                    echo ": heartbeat\n\n";
                }

                flush();
                sleep(2);
            }
        });

        $response->headers->set('Content-Type', 'text/event-stream');
        $response->headers->set('Cache-Control', 'no-cache');
        $response->headers->set('X-Accel-Buffering', 'no');

        return $response;
    }
}

7. Ressourcenverbrauch und Aktualität im Vergleich zu Polling

Der Ressourcenvorteil von SSE entsteht dadurch, dass Verbindungsaufbau und HTTP-Overhead nur einmal anfallen, während Polling diesen Overhead bei jedem einzelnen Zyklus wiederholt. Über HTTP/2 lassen sich zudem mehrere SSE-Streams über eine einzige zugrunde liegende TCP-Verbindung multiplexen, was das klassische Sechs-Verbindungen-Limit pro Domain aus HTTP/1.1 in der Praxis kaum noch relevant macht.

Bei der Aktualität liegt der Unterschied im Prinzip: Polling zeigt eine Änderung frühestens beim nächsten Zyklus an, im Mittel also nach der Hälfte des Intervalls, während ein SSE-Push nahezu in Echtzeit erfolgt, sobald der Server das Event schreibt. Das bedeutet in der Praxis eine spürbar geringere Latenz bei gleichzeitig geringerer Serverlast, allerdings um den Preis, dass der Server nun dauerhaft offene Verbindungen pro Client vorhalten muss, was bei sehr vielen gleichzeitigen Nutzern eigene Skalierungsüberlegungen erfordert.

8. Wo Server-Sent Events an ihre Grenzen stoßen

Server-Sent Events sind bewusst unidirektional: Der Client kann über denselben Kanal keine Daten zurücksenden, für eine Antwort braucht es einen separaten HTTP-Request. Für reine Anzeige-Updates wie Lagerbestand oder Status ist das kein Nachteil, für interaktive Szenarien mit häufigem Client-zu-Server-Verkehr wird dadurch aber schnell WebSocket die passendere Wahl. Außerdem transportiert SSE ausschließlich Text, binäre Daten müssen vorher base64-kodiert werden, was die Nutzlast spürbar vergrößert.

Ohne HTTP/2 greift bei vielen gleichzeitig geöffneten Tabs derselben Domain weiterhin das Sechs-Verbindungen-Limit des Browsers, wodurch weitere Tabs blockieren können, bis eine Verbindung frei wird. Manche restriktiv konfigurierten Unternehmens-Proxys puffern oder terminieren zudem lang laufende HTTP-Verbindungen nach einem festen Timeout, was in Firmennetzen gelegentlich zu unerwarteten Reconnects führt, die im eigenen Test-Setup ohne solchen Proxy gar nicht auffallen.

9. Praxisbeispiel: Ein Lagerbestand-Widget mit Live-Updates

In einem Hyvä-Theme lässt sich ein solches Widget als eigenständige Alpine-Komponente auf der Produktdetailseite platzieren, die unabhängig vom restlichen serverseitig gerenderten Markup ihren eigenen SSE-Kanal öffnet. Sinkt der Lagerbestand während eines Sale-Events unter einen kritischen Schwellenwert, zeigt das Widget sofort eine Warnung wie 'Nur noch 3 verfügbar', ohne dass die Seite neu geladen oder ein Intervall-Timer abgewartet werden muss.

Kombiniert man das mit dem oben gezeigten Reconnect-Mechanismus, bleibt das Widget auch bei kurzen Netzwerkausfällen funktionsfähig und zeigt in der Zwischenzeit klar erkennbar einen 'Live-Update pausiert'-Hinweis an, statt stillschweigend veraltete Zahlen zu präsentieren. Genau diese Transparenz über den Verbindungsstatus unterscheidet ein robustes SSE-Widget von einer naiven Implementierung, die Verbindungsprobleme einfach ignoriert.

Aspekt Polling Server-Sent Events WebSocket
Verbindungstyp Wiederholte einzelne HTTP-Requests Eine dauerhaft offene HTTP-Verbindung Eigenes Protokoll nach HTTP-Upgrade
Richtung Nur Client fragt aktiv nach Nur Server sendet an Client Bidirektional in beide Richtungen
Aktualität Abhängig vom Intervall Nahezu sofort bei Serverereignis Nahezu sofort, in beide Richtungen
Reconnect Kein Sonderfall, jeder Request ist neu Nativ im Browser eingebaut Muss manuell implementiert werden
Typischer Einsatz Seltene Änderungen, einfache Logik Statusänderungen, Lagerbestand, Feeds Chat, kollaborative Bearbeitung

Mironsoft

Alpine.js-Interaktivität für Hyvä-Frontends

Hyvä-Frontend, das mehr Interaktivität braucht, aber ohne React-Overhead?

Wir bauen interaktive Frontend-Komponenten für Hyvä-Themes mit Alpine.js, leichtgewichtig und ohne Build-Step-Komplexität, von einfachen Toggles bis zu komplexen Formular-Flows.

Custom-Komponenten

Interaktive Alpine.js-Komponenten für spezifische Shop-Anforderungen entwickeln.

Performance-Review

Bestehende Alpine.js-Implementierungen auf Reaktivitäts-Fallen und Performance prüfen.

Team-Schulung

Entwickler in Alpine.js-Patterns für Hyvä-Themes praxisnah einarbeiten.

10. Zusammenfassung

Server-Sent Events mit Alpine.js

Kernidee

Eine EventSource-Verbindung lässt den Server Änderungen aktiv pushen, statt dass der Client wiederholt nachfragt.

Wann einsetzen

Bei Anzeige-Updates ohne Rückkanal, etwa Lagerbestand, Status oder Benachrichtigungen.

Serverseitige Voraussetzung

Gestreamte Antwort mit deaktiviertem Buffering, korrekten Headern und regelmäßigem Heartbeat.

Größte Einschränkung

Rein unidirektional und textbasiert, für Client-zu-Server-Verkehr braucht es einen separaten Kanal.

11. FAQ: Server-Sent Events mit Alpine.js

1Was ist der Unterschied zwischen Server-Sent Events und WebSocket?
Server-Sent Events sind unidirektional und laufen über normales HTTP, WebSocket ist bidirektional und nutzt nach einem Upgrade ein eigenes Protokoll. SSE reicht für reine Server-zu-Client-Updates, WebSocket wird für echten Zwei-Wege-Verkehr gebraucht.
2Muss ich den Reconnect bei Server-Sent Events selbst implementieren?
Für die häufigsten Fälle nicht, der Browser reconnected automatisch nach einem Verbindungsabbruch. Nur wenn die Verbindung explizit geschlossen wird oder readyState endgültig auf CLOSED wechselt, braucht es eine eigene Wiederherstellungslogik.
3Kann ich mit Server-Sent Events auch Daten zum Server senden?
Nein, der EventSource-Kanal ist rein für Server-zu-Client-Daten gedacht. Für Anfragen in die andere Richtung wird ein separater HTTP-Request oder ein anderer Kanal wie WebSocket benötigt.
4Warum kommen meine SSE-Nachrichten beim Client verzögert an?
Meist liegt es an fehlendem Flushing auf Serverseite oder an einem Reverse-Proxy, der die Antwort puffert. Bei Nginx muss X-Accel-Buffering auf no gesetzt werden, damit Daten sofort weitergereicht werden.
5Funktionieren Server-Sent Events in allen Browsern?
Alle modernen Browser unterstützen die EventSource-API nativ, ältere Versionen des Internet Explorer allerdings nicht. Für produktive Projekte mit modernem Browser-Support ist das in der Praxis kaum noch relevant.
6Wie erkenne ich in Alpine.js, ob die SSE-Verbindung noch aktiv ist?
Über die readyState-Eigenschaft der EventSource-Instanz, die zwischen CONNECTING, OPEN und CLOSED unterscheidet. Diesen Wert sollte man in eine reaktive Alpine-Property spiegeln, um den Verbindungsstatus im Template anzeigen zu können.
7Wie viele gleichzeitige SSE-Verbindungen verträgt ein Server?
Das hängt vom eingesetzten Application-Server und Prozessmodell ab. Klassisches PHP-FPM mit blockierenden Worker-Prozessen skaliert bei vielen langlebigen Verbindungen schlechter als asynchrone Runtimes, weshalb dieser Aspekt vor dem produktiven Einsatz getestet werden sollte.
8Kann ich mehrere Event-Typen über einen einzigen SSE-Stream schicken?
Ja, über benannte Events mit dem event-Feld im Stream und addEventListener auf Clientseite lassen sich beliebig viele Ereignistypen über eine einzige Verbindung sauber getrennt verarbeiten.
9Sollte ich Server-Sent Events oder Polling für ein Lagerbestand-Widget verwenden?
Bei häufigen Änderungen und vielen gleichzeitigen Betrachtern, etwa während eines Sale-Events, ist SSE meist effizienter. Bei seltenen Änderungen und wenig Traffic kann ein einfaches Polling mit größerem Intervall ausreichend und einfacher zu betreiben sein.
10Wie schließe ich eine EventSource-Verbindung sauber in Alpine.js?
Die Instanz wird im init()-Hook auf this gespeichert und im destroy()-Hook der Komponente mit source.close() beendet, damit beim Entfernen der Komponente aus dem DOM keine offene Verbindung zurückbleibt.