NodeJSServer-Sent Events (SSE)

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

Text
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\n
SSE is a simple text protocol over one HTTP response — `data:` lines, blank line ends each event
SSE has no special protocol — it's an ordinary HTTP response with `Content-Type: text/event-stream` that the server simply never finishes. Each event is plain text: a `data:` line carrying the payload, optionally an `event:` line to name a custom event type and an `id:` line for resumption, and a **blank line** to mark the end of the event. Multi-line data uses multiple `data:` lines. A line starting with `:` is a comment, commonly sent periodically as a keep-alive to stop idle proxies closing the connection. Because it's just text over HTTP/1.1 chunked transfer, any HTTP client can consume it and any HTTP infrastructure can carry it.
An SSE endpoint in Express

JS
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()
  })
})
Always clean up on `req.on('close')` — unremoved listeners and intervals leak with every disconnect
The single most important SSE server detail is **cleanup on disconnect**. Each open SSE connection typically registers event listeners and timers (the keep-alive interval); if you don't remove them when the client goes away, every disconnect-and-reconnect leaks a listener and a timer, and since SSE clients reconnect *automatically and frequently*, these leaks accumulate fast into a [memory leak](/nodejs/memory-leaks) and the dreaded `MaxListenersExceededWarning`. Listen for `req.on('close')` (the client closed the connection) and tear down everything you set up — `clearInterval`, `emitter.off(...)`, `res.end()`. Treat the connection's lifecycle as: open → subscribe → stream → on close, unsubscribe.
The client — EventSource

JS
// 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...')
}
`EventSource` is built into browsers and reconnects automatically — no client library required
On the client, SSE needs **no library**: the browser's native `EventSource` opens the stream and dispatches events. `onmessage` handles default events; `addEventListener('name', ...)` handles events you tagged with an `event:` line. The standout feature is **automatic reconnection** — if the connection drops, `EventSource` retries on its own (you can tune the delay by sending a `retry:` line from the server), so you get resilient streaming for free. This is a major simplicity win over [WebSockets](/nodejs/websockets), where you implement reconnection yourself. (One caveat: native `EventSource` can't set custom headers, so token auth is usually done via cookies or a query param, or with a fetch-based SSE polyfill.)
Resuming with Last-Event-ID

JS
// 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...
})
Event IDs + the `Last-Event-ID` header let you replay missed events after a reconnect — no message loss
SSE has built-in support for **gap-free recovery**. When you give each event an `id:`, the browser remembers the last one received, and on automatic reconnect it sends that value back in the `Last-Event-ID` request header. Your server can read it and **replay** the events the client missed during the disconnect before resuming the live stream — so a brief network blip doesn't lose messages. Implementing equivalent missed-message recovery over raw WebSockets requires building your own sequencing and buffering. This combination of auto-reconnect plus resumable IDs is what makes SSE genuinely robust for important server-to-client feeds.
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 EventSource

Built-in WebSocket / library

Reconnect

Automatic + resumable (id)

Manual

Data

Text (UTF-8) only

Text and binary

Connection limit

HTTP/1.1: ~6 per domain per browser

Not similarly limited

SSE is one-way and text-only, and HTTP/1.1 caps ~6 connections per domain — know these limits
SSE's constraints define where it fits. It's **one-directional** (server→client) — client-to-server still goes through normal HTTP requests, which is fine for feeds but not for high-frequency two-way interaction (use [WebSockets](/nodejs/websockets) there). It carries **text only** (binary must be base64-encoded, which is wasteful). And on **HTTP/1.1** browsers limit you to ~6 concurrent connections per domain, so several SSE streams plus normal requests can starve each other — though **HTTP/2** multiplexing removes this limit and is the recommended deployment. Within those bounds — one-way, text, served over HTTP/2 — SSE is simpler, more proxy-friendly, and more resilient than WebSockets, making it the smart default for notifications, live dashboards, progress updates, and streaming AI responses.
Next
A different way to query your API efficiently: [Introduction to GraphQL](/nodejs/graphql-intro).