Server-Sent Events

Push From Server to Client — Without WebSockets

Server-Sent Events

SSE keeps a persistent HTTP connection open and streams events. The right tool for AI streaming, live tickers, log tails.

4 min read Level 2/5 #express#sse#real-time
What you'll learn
  • Implement an SSE endpoint
  • Consume with EventSource
  • Compare with WebSockets

Server-Sent Events (SSE) keep a single HTTP connection open and stream data: ... events to the browser. One-way (server → client), auto-reconnect, works through proxies.

It’s what most AI streaming UIs use to push tokens.

The Server

app.get("/events", (req, res) => {
  res.writeHead(200, {
    "content-type":  "text/event-stream",
    "cache-control": "no-cache",
    "connection":    "keep-alive",
  });

  // initial event
  res.write(`data: ${JSON.stringify({ type: "hello" })}\n\n`);

  // periodic events
  const id = setInterval(() => {
    res.write(`data: ${JSON.stringify({ time: Date.now() })}\n\n`);
  }, 1000);

  // clean up when client disconnects
  req.on("close", () => clearInterval(id));
});

The format is strict: data: <text>\n\n.

The Client

const es = new EventSource("/events");

es.onmessage = (e) => {
  const data = JSON.parse(e.data);
  console.log("got", data);
};

es.onerror = () => {
  console.error("disconnected — browser will retry automatically");
};

EventSource auto-reconnects when the connection drops.

Named Events

event: tick
data: {"value":42}

event: alarm
data: {"severity":"high"}
res.write(`event: tick\ndata: ${JSON.stringify({ value: 42 })}\n\n`);
res.write(`event: alarm\ndata: ${JSON.stringify({ severity: "high" })}\n\n`);

Client listens by event name:

es.addEventListener("tick",  (e) => { /* ... */ });
es.addEventListener("alarm", (e) => { /* ... */ });

AI Streaming Pattern

app.post("/chat", async (req, res) => {
  res.writeHead(200, {
    "content-type": "text/event-stream",
    "cache-control": "no-cache",
    "connection": "keep-alive",
  });

  for await (const chunk of llmClient.stream(req.body.prompt)) {
    res.write(`data: ${JSON.stringify({ delta: chunk })}\n\n`);
  }
  res.write("data: [DONE]\n\n");
  res.end();
});

Token-by-token UI without WebSockets.

SSE vs WebSockets

SSEWebSocket
DirectionServer → ClientBoth ways
ProtocolHTTPCustom (upgraded from HTTP)
ReconnectBuilt-inRoll your own
Proxies/firewallsFriendlySometimes blocked
Browser supportUniversal (no IE)Universal
FormatText onlyText or binary

If the client doesn’t need to send (or sends rarely via plain POST), SSE is simpler. WebSockets only when bidirectional message-rate matters.

Behind A Reverse Proxy

Nginx buffers responses by default — SSE breaks until you disable:

location /events {
  proxy_pass http://app;
  proxy_buffering off;
  proxy_cache off;
  proxy_set_header Connection '';
  proxy_http_version 1.1;
  chunked_transfer_encoding off;
}

The same applies behind Cloudflare and most other proxies — check the docs.

WebSockets →