„Küldjünk rá WebSocketet” – hangzik el, amikor egy felületen egy állapotnak magától kell frissülnie. Pedig ha a böngészőnek csak hallgatnia kell – build állapota, rendelés státusza, értesítések, futó import naplója –, a Server-Sent Events (SSE) gyakran kevesebb mozgó alkatrész. Egy hosszú HTTP-válasz, amelynek a szerver új sorokat ír. A csapda nem a három sor klienskód: az újracsatlakozás és a köztes proxy teszi production problémává.

Ez HTTP, csak nem akar befejeződni
Az EventSource a text/event-stream választ olvassa. Egy üzenet mezőkből áll; az üres sor zárja le és ekkor érkezik meg a böngészőben. Az event: névvel saját eseménytípust adhatunk, a data: sorokból lesz a payload, az id: pedig nem díszlet: ez a visszatérés kulcsa. A formátum részleteit a HTML szabvány SSE-fejezete rögzíti.
A kliensoldal szinte sértően rövid:
const events = new EventSource('/events');
events.addEventListener('build', ({ data, lastEventId }) => {
const build = JSON.parse(data);
renderBuild(build, lastEventId);
});
A kapcsolat hibájánál az objektum alapból újranyitja a streamet. A szerver retry: 5000 sorral mondhatja meg, hány ezredmásodperc után próbálkozzon újra. Ez nem megbízható üzenetsor: nincs kliens ACK, nincs tranzakció, nincs pontosan egyszeri kézbesítés. Viszont a szerver tudhatja, honnan kér visszajátszást.
A Last-Event-ID nem varázslat, hanem cursor
Ha egy elküldött blokkban volt id: 42, majd megszakad a kapcsolat, az EventSource a következő kérésben Last-Event-ID: 42 fejlécet küld. A szerver ebből például a 42-nél nagyobb eseményeket olvassa ki egy tartós, sorrendezett naplóból. Na, ezt nem tudtam, amikor először használtam: az id önmagában semmit nem javít; egy memóriában levő tömbbel csak addig „megbízható”, amíg ugyanaz a processz épp nem indult újra.
Ezért az ID legyen valódi resume cursor: adatbázis-sorszám, broker offset vagy más monoton rendezhető érték. A kliensnek az ismétlést el kell viselnie, a szervernek pedig ki kell mondania, mi történik, ha a cursor már kiesett a megőrzési ablakból: teljes állapot-szinkron, nem pedig csendes adatvesztés. Az MDN SSE útmutatója is kiemeli az automatikus reconnectet és a kommentként küldött heartbeatet.
Az alábbi minimális Node-szerver futtatható példa. A flushHeaders() azonnal kiküldi a fejléceket; a Node HTTP dokumentációja szerint erre való, ha a válasz törzse később jön. A : ping SSE-komment: a kliens figyelmen kívül hagyja, a kapcsolatot viszont nem hagyja teljesen néma csőnek.
import { createServer } from 'node:http';
const history = [
{ id: 41, status: 'queued' },
{ id: 42, status: 'running' },
{ id: 43, status: 'finished' },
];
function send(res, message) {
res.write(`id: ${message.id}\n`);
res.write('event: build\n');
res.write(`data: ${JSON.stringify(message)}\n\n`);
}
createServer((req, res) => {
if (req.url !== '/events') return res.writeHead(404).end();
const after = Number.parseInt(req.headers['last-event-id'] ?? '0', 10) || 0;
res.writeHead(200, {
'Content-Type': 'text/event-stream; charset=utf-8',
'Cache-Control': 'no-cache, no-transform',
Connection: 'keep-alive',
'X-Accel-Buffering': 'no',
});
res.flushHeaders();
res.write(': connected\n\n');
for (const message of history.filter(({ id }) => id > after)) send(res, message);
const heartbeat = setInterval(() => res.write(': ping\n\n'), 15_000);
req.on('close', () => clearInterval(heartbeat));
}).listen(8787);
A reverse proxy is része a protokollnak
Lokálisan működik, Nginx mögött pedig a kliens percenként, csomagban kapja meg az „élő” adatot. Ismerős kis horror. Az Nginx válaszbufferelése erre teljesen racionális alapbeállítás, SSE-hez viszont rossz. Az Nginx saját proxy dokumentációja szerint a proxy_buffering off szinkronosan továbbítja a választ, és az upstream X-Accel-Buffering: no fejléce is vezérelheti ezt. A deploy-konfigurációban ezt, valamint a proxy read timeoutját kifejezetten az eseményútvonalra állítsuk be; a szerver által küldött heartbeat csak ezután tud életben tartani bármit.
Másik kellemetlen részlet: natív EventSource-szal nem állíthatsz tetszőleges Authorization fejlécet. Azonos origón a secure, HTTP-only session cookie kényelmes; keresztorigón a CORS és a credential-beállítások is belépnek a képbe. Tokent query stringbe tenni azért, hogy működjön a demo, a naplóidnak és a böngészőelőzménynek ajándék.
Több szerverpéldánynál a visszajátszásnak is közös helyről kell jönnie. A sticky session itt csak egy kényelmes véletlen: egy deploy, egy podhiba vagy egy böngésző új kapcsolatútja már másik processzhez visz. Mérném az aktív streameket, a reconnectek számát és azt az időt, amely az esemény tartós rögzítése és a kliensoldali feldolgozás között telik el. A nyitott socketek száma önmagában legfeljebb azt bizonyítja, hogy sok a nyitott socket.
HTTP/1.1 esetén számolj az egy origóra jutó alacsony böngészős kapcsolatszámmal: több fül és több stream együtt gyorsan beleáll a plafonba. HTTP/2-n a streamek száma tárgyalt limit, ezért ott sokkal barátságosabb a kép, de a proxy timeoutját ettől még nem oldja meg a varázslómanó.
Mikor választanám?
Productionben használnám egyirányú státuszokhoz, naplókhoz és értesítésekhez, ahol a kliens a normál HTTP API-n indítja a műveletet, az SSE-n pedig követi. Előtte megtervezném a visszajátszási ablakot, az idempotens kliensfrissítést, a heartbeatet és proxyval mért viselkedést. Nem ezt választanám chathez, közös szerkesztéshez vagy gyakori kliens–szerver parancsokhoz: ott a WebSocket vagy egy kétirányú protokoll őszintébb. Az SSE ereje épp az, hogy nem próbál többnek látszani egy gondosan befejezetlen HTTP-válasznál.