Most "real-time" features on the web don't need a bidirectional connection — a stock ticker, a build log, a notification feed, or an AI response streaming token by token are all cases where only the server has something to say. WebSockets can do this, but they bring a protocol upgrade, a custom message framing scheme, and manual reconnection logic you have to write yourself. Server-Sent Events (SSE) solve the one-way case with a plain HTTP response the browser already knows how to keep open, retry, and parse.
SSE vs WebSockets: when one-way is enough
WebSockets are a separate protocol (ws://) that gives you a full-duplex socket — the client can push messages back at any time. SSE is just HTTP with Content-Type: text/event-stream and a response that never closes; the client can only receive. If your feature is genuinely two-way — a chat app, a collaborative editor, multiplayer cursors — use WebSockets. If the client's only outbound need is the initial request (or an occasional separate POST), SSE gets you most of the benefit with a fraction of the infrastructure: it works over plain HTTP/1.1 or HTTP/2, passes through most proxies and load balancers without special config, and the browser handles reconnection for you.
The EventSource API
The client side is a few lines. No library, no framework — EventSource is a built-in browser API.
const source = new EventSource('/api/notifications/stream');
source.addEventListener('message', (e) => {
const payload = JSON.parse(e.data);
console.log('notification:', payload);
});
// Named events let you route different message types
source.addEventListener('order-status', (e) => {
updateOrderBadge(JSON.parse(e.data));
});
source.onerror = (err) => {
// Fires on network drop too — EventSource will auto-reconnect
// using the browser's built-in backoff, no manual retry loop needed.
console.warn('SSE connection interrupted, browser will retry', err);
};
// Close explicitly when the component unmounts or the user navigates away
// source.close();
The browser tracks a Last-Event-ID automatically and resends it as a header on reconnect, so the server can resume from where the client left off instead of replaying the whole stream.
The wire format and reconnection
An SSE stream is plain text, one event per block, separated by a blank line. Three fields matter in practice: data: for the payload, id: to mark a resumable position, and retry: to tell the browser how long to wait before reconnecting.
id: 42
event: order-status
data: {"orderId": 918, "status": "shipped"}
retry: 3000
id: 43
data: {"orderId": 919, "status": "processing"}
Nginx and some CDNs buffer responses before forwarding them, which defeats streaming entirely — the client gets nothing until the connection closes. Set X-Accel-Buffering: no on the response (and disable proxy_buffering for the route) or the "real-time" stream will arrive in one lump.
Building the server side
The server just needs to keep the response open and flush each chunk as it's written. Here's a minimal Express example.
app.get('/api/notifications/stream', (req, res) => {
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
});
let id = Number(req.headers['last-event-id'] || 0);
const send = (event, data) => {
id += 1;
res.write(`id: ${id}\n`);
if (event) res.write(`event: ${event}\n`);
res.write(`data: ${JSON.stringify(data)}\n\n`);
};
const interval = setInterval(() => {
send('heartbeat', { ts: Date.now() });
}, 15000); // keep intermediary proxies from timing out the idle connection
const unsubscribe = notifications.subscribe((n) => send('order-status', n));
req.on('close', () => {
clearInterval(interval);
unsubscribe();
});
});
Most load balancers and reverse proxies drop idle connections after 30-60 seconds. A periodic comment or heartbeat event keeps bytes flowing so the connection isn't mistaken for dead — cheaper than reconnecting every minute.
Where SSE falls short
Browsers cap concurrent HTTP/1.1 connections per origin at six — if a user has several tabs open to the same site, each holding an SSE connection, they can exhaust that limit and block other requests. HTTP/2 removes this ceiling by multiplexing streams over one connection, so serving SSE over HTTP/2 matters more than it looks. SSE also can't send binary data (everything is UTF-8 text) and has no built-in way for the client to send data back on the same connection — if the client needs to acknowledge or reply, that's a separate request.
Wrapping up
SSE is the right default for server-to-client streaming whenever the client isn't talking back on the same channel: it rides on ordinary HTTP, the browser already implements reconnection and event parsing, and there's no new protocol to operate. Reach for WebSockets only when the interaction is genuinely bidirectional — otherwise the extra complexity buys nothing you didn't already get from EventSource.
Independent software engineer in Nairobi specialising in Acumatica customisations, Laravel backends, and tax fiscalisation integrations across East and Southern Africa.