Server-Sent Events are the simplest way to push data from server to browser. They run over ordinary HTTP, reconnect automatically, and need no additional protocol. For dashboards, notifications, progress updates, and streaming AI responses โ anything where data flows one way โ SSE is usually the right choice over WebSockets.
๐ Table of Contents
SSE or WebSockets?
| SSE | WebSockets | |
|---|---|---|
| Direction | Server to client only | Bidirectional |
| Protocol | Plain HTTP | Upgrade to ws:// |
| Auto-reconnect | Built in | You implement it |
| Data format | UTF-8 text | Text or binary |
| Proxy friendliness | Usually fine | Needs upgrade support |
| Complexity | Low | Higher |
Use SSE for live dashboards, notification feeds, job progress, log tailing, and token-by-token AI output. Use WebSockets when the client sends frequent messages too โ chat, collaborative editing, multiplayer games.
A common and sensible hybrid: SSE for server updates, ordinary HTTP POST requests for client actions. That covers most applications without a second protocol.
The Wire Format
SSE is a plain text stream with a small, strict format.
data: hello world
event: userUpdate
data: {"id":1,"name":"Ada"}
id: 42
retry: 5000
data: message with an id and a retry hint
Two rules cause almost every bug. Each message ends with two newlines. And multi-line data needs a data: prefix on every line, which is why JSON payloads must not contain raw newlines.
Server: Node.js with Express
import express from 'express';
const app = express();
// Track connected clients so we can broadcast.
const clients = new Set();
app.get('/api/events', (req, res) => {
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache, no-transform',
'Connection': 'keep-alive',
// Tell nginx not to buffer this response.
'X-Accel-Buffering': 'no',
});
// Flush headers immediately so the client's connection opens.
res.flushHeaders();
const client = { id: Date.now(), res };
clients.add(client);
send(res, { type: 'connected', at: new Date().toISOString() });
// A comment line every 25s keeps proxies from closing an idle connection.
const heartbeat = setInterval(() => {
res.write(': heartbeat\n\n');
}, 25_000);
req.on('close', () => {
clearInterval(heartbeat);
clients.delete(client);
});
});
function send(res, data, event) {
if (event) res.write(`event: ${event}\n`);
// JSON.stringify never emits a raw newline, which keeps the frame valid.
res.write(`data: ${JSON.stringify(data)}\n\n`);
}
export function broadcast(data, event) {
for (const client of clients) {
send(client.res, data, event);
}
}
app.listen(3000);
Three details matter here. flushHeaders() opens the stream immediately rather than waiting for the first write. The heartbeat comment prevents intermediaries from dropping an idle connection. And X-Accel-Buffering: no stops nginx from buffering the response, which is the single most common reason SSE works locally and fails in production.
Client: React
import { useEffect, useRef, useState } from 'react';
export function useEventStream(url) {
const [messages, setMessages] = useState([]);
const [status, setStatus] = useState('connecting');
const sourceRef = useRef(null);
useEffect(() => {
const source = new EventSource(url, { withCredentials: true });
sourceRef.current = source;
source.onopen = () => setStatus('open');
source.onmessage = (e) => {
const data = JSON.parse(e.data);
setMessages(prev => [...prev, data]);
};
// Named events need their own listener.
source.addEventListener('userUpdate', (e) => {
const data = JSON.parse(e.data);
setMessages(prev => [...prev, { ...data, kind: 'userUpdate' }]);
});
source.onerror = () => {
// EventSource reconnects on its own unless the state is CLOSED.
setStatus(source.readyState === EventSource.CLOSED ? 'closed' : 'reconnecting');
};
return () => source.close();
}, [url]);
return { messages, status };
}
export function LiveFeed() {
const { messages, status } = useEventStream('/api/events');
return (
<div>
<p>Status: {status}</p>
<ul>
{messages.map((m, i) => <li key={i}>{JSON.stringify(m)}</li>)}
</ul>
</div>
);
}
Returning source.close() from the effect is not optional. Without it, React Strict Mode’s double-mount leaves an orphaned connection, and navigating around the app accumulates open streams until the browser’s per-domain connection limit is reached and everything stalls.
Resuming After a Disconnect
When you send an id: field, the browser stores it and sends it back as Last-Event-ID on reconnection. That lets you replay only what was missed.
app.get('/api/events', (req, res) => {
// ... headers as above ...
const lastId = req.headers['last-event-id'];
if (lastId) {
for (const event of getEventsSince(Number(lastId))) {
res.write(`id: ${event.id}\n`);
res.write(`data: ${JSON.stringify(event.payload)}\n\n`);
}
}
});
This turns SSE from best-effort into something closer to reliable delivery, which matters for notification feeds where a dropped message is visible to the user.
Authentication
The native EventSource API cannot set custom headers, which surprises people building token-authenticated APIs. Three workable approaches:
Cookies โ simplest. Pass withCredentials: true and let the session cookie authenticate the request as it would any other.
A short-lived token in the query string โ acceptable only if the token is single-use and expires in minutes, because URLs end up in server logs.
fetch with a streaming reader โ full header control, at the cost of implementing reconnection yourself.
async function streamWithAuth(url, token, onMessage) {
const res = await fetch(url, {
headers: { Authorization: `Bearer ${token}` },
});
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const frames = buffer.split('\n\n');
buffer = frames.pop() ?? ''; // keep the incomplete frame
for (const frame of frames) {
const line = frame.split('\n').find(l => l.startsWith('data:'));
if (line) onMessage(JSON.parse(line.slice(5).trim()));
}
}
}
Note the buffering: a network chunk can split a frame in half, so you must keep the remainder and only parse complete frames. Parsing each chunk independently produces intermittent JSON errors that are painful to diagnose.
Streaming AI Responses
The pattern behind token-by-token output in chat interfaces.
app.post('/api/chat', async (req, res) => {
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache, no-transform',
'X-Accel-Buffering': 'no',
});
res.flushHeaders();
try {
for await (const chunk of generateResponse(req.body.prompt)) {
res.write(`data: ${JSON.stringify({ token: chunk })}\n\n`);
}
res.write('data: [DONE]\n\n');
} catch (err) {
res.write(`event: error\ndata: ${JSON.stringify({ message: err.message })}\n\n`);
} finally {
res.end();
}
});
Proxy and Deployment Configuration
Most production SSE failures are proxy buffering. The header alone is not always enough.
# nginx
location /api/events {
proxy_pass http://backend;
proxy_http_version 1.1;
proxy_set_header Connection '';
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 24h;
chunked_transfer_encoding off;
}
Also be aware that serverless platforms often cap response duration, which makes long-lived SSE connections unsuitable. Check your platform’s limit before designing around it. And with HTTP/1.1, browsers allow only about six connections per domain โ HTTP/2 removes that constraint, so serve SSE over HTTP/2 where you can.
Common Mistakes
Forgetting the second newline. The message is never dispatched and the client appears to hang.
Not closing the EventSource on unmount. Connections accumulate until the browser limit is hit.
Raw newlines inside data. Always JSON.stringify the payload.
No heartbeat. Idle connections get closed by intermediaries after a minute or two.
Proxy buffering left on. Everything works locally, then nothing arrives in production until the response ends.
Using SSE for bidirectional traffic. If the client sends messages frequently, use WebSockets.
Conclusion
SSE gives you server-to-client streaming over ordinary HTTP with automatic reconnection and very little code. Get five things right: end every message with two newlines, send a periodic heartbeat comment, disable proxy buffering with both the header and the nginx configuration, close the EventSource in your effect cleanup, and use id: with Last-Event-ID when missed messages would be noticed. Reach for WebSockets only when the client genuinely needs to send frequent messages back.
๐ You might also like
๐ Share this article




โ๏ธ Leave a Comment