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.
Inhaltsverzeichnis
- 1. Warum wiederholtes Nachfragen an seine Grenzen stößt
- 2. Was Server-Sent Events technisch ausmacht
- 3. Eine EventSource-Verbindung in x-data initialisieren
- 4. Empfangene Events parsen und reaktiven State aktualisieren
- 5. Reconnect-Verhalten: was der Browser automatisch übernimmt und was nicht
- 6. Serverseitige Voraussetzungen für einen SSE-Stream
- 7. Ressourcenverbrauch und Aktualität im Vergleich zu Polling
- 8. Wo Server-Sent Events an ihre Grenzen stoßen
- 9. Praxisbeispiel: Ein Lagerbestand-Widget mit Live-Updates
- 10. Zusammenfassung
- 11. FAQ
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.