3 Min. Lesezeit
Das leere Polling
Ein Kollege schickt dir etwas. Du solltest dafür nicht die Seite neu laden müssen.
Die erste Version pollte: Alle paar Sekunden fragte der Browser „etwas Neues?“ und hörte meist „nein“. Ein kürzeres Intervall vervielfacht nur die leeren Anfragen, und die Benachrichtigung kommt trotzdem bis zu ein Intervall zu spät. Polling skaliert die Kosten des Fragens, nicht die Geschwindigkeit des Erfahrens.
Das Problem ist einseitig: Der Server weiß, wann etwas passiert, der Client muss es nur erfahren. Das ist eine Übertragung, kein Gespräch.
Server-Sent Events
SSE passt genau: eine langlebige HTTP-Verbindung, in die der Server pusht. Kein Polling, kein WebSocket-Handshake für einen Kanal, der nur in eine Richtung fließt. In Spring Boot ist es ein SseEmitter pro Nutzer, gehalten in einer Registry:
@GetMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter stream(@AuthenticationPrincipal User user) {
SseEmitter emitter = new SseEmitter(Duration.ofMinutes(30).toMillis());
registry.add(user.id(), emitter);
emitter.onCompletion(() -> registry.remove(user.id(), emitter));
emitter.onTimeout(() -> registry.remove(user.id(), emitter));
return emitter;
}
void notify(UserId recipient, Notification event) {
for (SseEmitter emitter : registry.get(recipient)) {
try {
emitter.send(SseEmitter.event().name("stream").data(event));
} catch (IOException dead) {
registry.remove(recipient, emitter);
}
}
}Die Custom-Header-Falle
Der Stream liegt hinter derselben Authentifizierung wie alles andere: einem Authorization-Header. Das native EventSource des Browsers kann keinen senden.
event-source-polyfill spricht dasselbe Protokoll, akzeptiert aber echte Header. Dieser Header war der einzige Grund, danach zu greifen.
Reconnect ist ein separates Thema: Sowohl das native EventSource als auch das Polyfill verbinden sich von selbst neu. Hier wird deren Standard-Retry durch ein gedeckeltes exponentielles Backoff ersetzt:
import { EventSourcePolyfill } from "event-source-polyfill";
connect(retry = 0): void {
const stream = new EventSourcePolyfill("/api/stream", {
headers: { Authorization: `Bearer ${this.token}` },
});
stream.addEventListener("stream", (event) => {
this.show(JSON.parse((event as MessageEvent).data));
retry = 0;
});
stream.onerror = () => {
stream.close();
const delay = Math.min(30_000, 1_000 * 2 ** retry);
window.setTimeout(() => this.connect(retry + 1), delay);
};
}Dieses Backoff ist der Unterschied zwischen einer Demo und etwas, worauf man sich einen Arbeitstag lang verlässt: Eine abgebrochene Verbindung erholt sich von selbst, und niemand muss neu laden.
Dieselbe Idee, servergepushte Updates über eine langlebige Verbindung, habe ich später in eine kleine Kotlin-Bibliothek gegossen: stream-pulse, veröffentlicht auf JitPack.
Ergebnis
Benachrichtigungen kommen in dem Moment an, in dem der Kollege handelt, nicht ein Intervall zu spät. Der Server beantwortet keine leeren Polls. Eine abgebrochene Verbindung verbindet sich selbst neu.
Das Werkzeug war klein. Die Ingenieursarbeit war alles drumherum.
Beitrag teilen