What Are Server-Sent Events?
Server-Sent Events (SSE) is a server push technology that allows a client to receive automatic updates from a server over a single, long-lived HTTP connection. Unlike WebSockets, SSE is unidirectional, built on plain HTTP, and natively supported by every modern browser.
The EventSource interface enables servers to push data to Web pages over HTTP or using dedicated server-push protocols.
If your use case is streaming data from the server — think live feeds, notifications, log tailing, or AI token streaming — SSE is often the simpler, better choice.
SSE vs WebSockets
- • Unidirectional — simple mental model
- • Plain HTTP — no protocol upgrade, proxy-friendly
- • Auto-reconnect built into the browser API
- • Lightweight to implement on the server
- • Event IDs let you resume from where you left off
- • Works with standard HTTP/2 multiplexing
- • Server → Client only (client can't send data back on the same connection)
- • Text-based only — no binary data
- • Limited to ~6 concurrent connections per domain in HTTP/1.1
- • Not ideal for high-frequency bidirectional communication (e.g. multiplayer games)
Rule of thumb: If the client doesn’t need to send data back over the same connection, use SSE. Save WebSockets for truly bidirectional use cases like chat or collaborative editing.
The SSE Protocol
The protocol is dead simple. The server responds with specific headers and sends messages in a plain-text format.
Required response headers:
Content-Type: text/event-streamConnection: keep-aliveCache-Control: no-cacheMessage format:
event: eventName\ndata: your payload here\nid: optional-id\nretry: 3000\n\nA double newline (\n\n) signals the end of a message. Only the data field is required — everything else is optional.
| Field | Purpose |
|---|---|
data | The message payload (required) |
event | Custom event name (defaults to "message") |
id | Event ID for reconnection resume via Last-Event-ID |
retry | Reconnection interval in milliseconds |
Implementation
Step 1: Build the SSE Server
import http from "node:http";
const server = http.createServer((req, res) => { if (req.url === "/events") { res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", Connection: "keep-alive", "Access-Control-Allow-Origin": "*", });
// Send a timestamp every 2 seconds const interval = setInterval(() => { const data = JSON.stringify({ time: new Date().toISOString(), message: "Hello from SSE!", }); res.write(`data: ${data}\n\n`); }, 2000);
// Clean up on disconnect req.on("close", () => { clearInterval(interval); res.end(); console.log("Client disconnected"); });
return; }
res.writeHead(404).end("Not found");});
server.listen(3000, () => { console.log("SSE server running on http://localhost:3000");});import express from "express";
const app = express();
app.get("/events", (req, res) => { res.set({ "Content-Type": "text/event-stream", "Cache-Control": "no-cache", Connection: "keep-alive", }); res.flushHeaders();
const interval = setInterval(() => { const data = JSON.stringify({ time: new Date().toISOString(), message: "Hello from SSE!", }); res.write(`data: ${data}\n\n`); }, 2000);
req.on("close", () => { clearInterval(interval); res.end(); });});
app.listen(3000, () => { console.log("SSE server running on http://localhost:3000");});import { Hono } from "hono";import { streamSSE } from "hono/streaming";
const app = new Hono();
app.get("/events", (c) => { return streamSSE(c, async (stream) => { let id = 0; while (true) { await stream.writeSSE({ event: "message", data: JSON.stringify({ time: new Date().toISOString(), message: "Hello from SSE!", }), id: String(id++), }); await stream.sleep(2000); } });});
export default app;Step 2: Connect from the Browser
The browser’s built-in EventSource API handles connection, reconnection, and parsing automatically.
const source = new EventSource("http://localhost:3000/events");
// Default "message" eventssource.onmessage = (event) => { const data = JSON.parse(event.data); console.log("Received:", data);};
// Named eventssource.addEventListener("notification", (event) => { console.log("Notification:", JSON.parse(event.data));});
// Error handlingsource.onerror = (err) => { console.error("SSE error:", err); // EventSource will auto-reconnect by default};
// To manually close:// source.close();Step 3: Send Named Events and IDs (Optional)
Named events and IDs give you more control. IDs are especially useful — on reconnect, the browser sends the last received ID via the Last-Event-ID header so you can resume the stream.
// Server-side: sending a named event with an IDlet eventId = 0;
function sendEvent(res, eventName, payload) { eventId++; res.write(`id: ${eventId}\n`); res.write(`event: ${eventName}\n`); res.write(`data: ${JSON.stringify(payload)}\n\n`);}
// UsagesendEvent(res, "notification", { type: "info", text: "Deployment complete",});
sendEvent(res, "metric", { cpu: 42.5, memory: 68.2,});// Client-side: listening for named eventssource.addEventListener("notification", (e) => { console.log("Notification:", JSON.parse(e.data));});
source.addEventListener("metric", (e) => { console.log("Metric:", JSON.parse(e.data));});Handling Reconnection and Resumption
One of SSE’s best features is automatic reconnection. When the connection drops, EventSource reconnects and sends the Last-Event-ID header. You can use this server-side to replay missed events.
app.get("/events", (req, res) => { res.set({ "Content-Type": "text/event-stream", "Cache-Control": "no-cache", Connection: "keep-alive", }); res.flushHeaders();
const lastId = parseInt(req.headers["last-event-id"] || "0", 10);
// Replay any events the client missed for (const event of getEventsSince(lastId)) { res.write(`id: ${event.id}\n`); res.write(`data: ${JSON.stringify(event.data)}\n\n`); }
// Then continue streaming new events...});You need to store events somewhere (in-memory, Redis, database) if you want to support resumption.
Without stored events, Last-Event-ID is useless.
Real-World Use Case: AI Token Streaming
This is probably the most common SSE use case today. Here’s how you’d stream tokens from an LLM API:
app.get("/ai/stream", async (req, res) => { res.set({ "Content-Type": "text/event-stream", "Cache-Control": "no-cache", Connection: "keep-alive", }); res.flushHeaders();
const response = await openai.chat.completions.create({ model: "gpt-4o", messages: [{ role: "user", content: req.query.prompt }], stream: true, });
for await (const chunk of response) { const content = chunk.choices[0]?.delta?.content; if (content) { res.write(`data: ${JSON.stringify({ token: content })}\n\n`); } }
res.write("data: [DONE]\n\n"); res.end();});// Clientconst source = new EventSource( `/ai/stream?prompt=${encodeURIComponent("Explain SSE in one sentence")}`,);
source.onmessage = (e) => { const data = JSON.parse(e.data); if (data === "[DONE]") { source.close(); return; } document.getElementById("output").textContent += data.token;};EventSource only supports GET requests. If you need to POST a body (common with AI APIs), use
the fetch API with a readable stream instead — see the gotchas section below.
SSE with fetch (POST Support)
Since EventSource is GET-only, here’s how to consume SSE with fetch for POST requests:
async function fetchSSE(url, body) { const response = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), });
const reader = response.body.getReader(); const decoder = new TextDecoder();
while (true) { const { done, value } = await reader.read(); if (done) break;
const text = decoder.decode(value, { stream: true });
// Parse SSE lines for (const line of text.split("\n")) { if (line.startsWith("data: ")) { const data = line.slice(6); if (data === "[DONE]") return; console.log(JSON.parse(data)); } } }}
fetchSSE("/ai/stream", { prompt: "Explain SSE" });When using fetch instead of EventSource, you lose auto-reconnect and Last-Event-ID. You’ll
need to implement those yourself.
Common Gotchas
Buffering by reverse proxies: Nginx, Cloudflare, and similar proxies may buffer SSE responses.
For Nginx, add proxy_buffering off; and X-Accel-Buffering: no to your response headers.
HTTP/1.1 connection limit: Browsers cap concurrent connections to the same origin at ~6. Each SSE connection counts. Use HTTP/2 (which multiplexes) or limit the number of SSE endpoints a single page opens.
Always call res.flushHeaders() in Express/Hono to send headers immediately. Without this,
the response might be buffered and the client won’t receive events until the buffer fills.
Further Reading
TL;DR
SSE is HTTP’s built-in real-time streaming primitive. It’s simpler than WebSockets, works through proxies, auto-reconnects, and is perfect for the most common real-time pattern: server pushing data to clients. Next time you reach for a WebSocket library to send notifications or stream AI responses, consider if a few response headers and res.write() is all you actually need.