Server-Sent Events (SSE)
Server-Sent Events give you a one-way stream from server to client over a single, long-lived HTTP response. The server keeps the connection open and writes events as they happen; the browser's built-in EventSource consumes them and — uniquely — reconnects automatically if the connection drops. SSE is plain HTTP (no protocol upgrade), so it works through proxies and firewalls that trip up WebSockets, and it's dramatically simpler. For server-to-client feeds — notifications, live dashboards, progress, log tailing, AI token streaming — SSE is often the right, underused choice. This page covers the wire format, building an SSE endpoint, the client, auto-reconnect with event IDs, and SSE's limits.
The wire format
Response headers:
Content-Type: text/event-stream
Cache-Control: no-cache
Connection: keep-alive
Then the server writes events as plain text, each terminated by a blank line:
data: hello world\n\n
event: price\n
data: {"symbol":"ACME","price":42.10}\n\n
id: 1001\n
data: an event with an id (used for resuming)\n\n
: this is a comment / keep-alive ping\n\nAn SSE endpoint in Express
app.get('/events', (req, res) => {
// 1. Set the SSE headers and flush them to open the stream:
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
})
// 2. Helper to write a well-formed event:
const send = (data, event) => {
if (event) res.write(`event: ${event}\n`)
res.write(`data: ${JSON.stringify(data)}\n\n`) // blank line terminates
}
send({ message: 'connected' })
// 3. Push events as things happen (here: a subscription to some source):
const onUpdate = (payload) => send(payload, 'update')
emitter.on('update', onUpdate)
// 4. Keep-alive comment so proxies don't close an idle connection:
const keepAlive = setInterval(() => res.write(': ping\n\n'), 15000)
// 5. CRITICAL: clean up when the client disconnects:
req.on('close', () => {
clearInterval(keepAlive)
emitter.off('update', onUpdate) // stop leaking listeners
res.end()
})
})The client — EventSource
// Built into every modern browser — no library needed:
const source = new EventSource('/events')
source.onmessage = (e) => { // default (unnamed) events
console.log('data:', JSON.parse(e.data))
}
source.addEventListener('update', (e) => { // named events (event: update)
render(JSON.parse(e.data))
})
source.onerror = () => {
// EventSource reconnects on its own; this just signals a transient drop.
console.log('connection lost — auto-reconnecting...')
}Resuming with Last-Event-ID
// Server: tag each event with an id so the client can resume after a drop:
res.write(`id: ${event.id}\n`)
res.write(`data: ${JSON.stringify(event)}\n\n`)
// On reconnect, the browser AUTOMATICALLY sends the last id it saw:
app.get('/events', (req, res) => {
const lastId = req.headers['last-event-id'] // browser sets this on reconnect
if (lastId) {
// Replay events the client missed while disconnected:
for (const ev of getEventsSince(lastId)) send(ev)
}
// ...continue streaming new events...
})SSE's limits — and when to choose it
Aspect | SSE | WebSockets |
|---|---|---|
Direction | Server → client only | Bidirectional |
Protocol | Plain HTTP (proxy-friendly) | Upgraded protocol |
Client | Built-in | Built-in |
Reconnect | Automatic + resumable ( | Manual |
Data | Text (UTF-8) only | Text and binary |
Connection limit | HTTP/1.1: ~6 per domain per browser | Not similarly limited |