Why SSE Instead of WebSockets or Polling?
If your API needs to push data to clients as it becomes available — progress updates, live logs, notifications, or token-by-token LLM output — you have three options: short polling, WebSockets, or Server-Sent Events.
Polling is wasteful: the client repeatedly asks "anything new?" and usually hears "no." WebSockets are powerful but bidirectional and stateful, which is overkill when only the server needs to talk. SSE sits in the sweet spot: a single long-lived HTTP response that streams text events from server to client, with automatic reconnection built into the browser.
Because SSE is just HTTP, it works through proxies and load balancers, needs no special protocol upgrade, and is trivial to authenticate with the headers you already use.
The Wire Format
SSE is astonishingly simple. The server responds with Content-Type: text/event-stream and writes plain-text blocks separated by blank lines:
event: message
data: {"progress": 25}
event: message
data: {"progress": 50}
event: done
data: {"status": "complete"}
Each field is name: value. The important ones are data, event (a custom event name), id (used for resuming), and retry (reconnect delay in ms). A blank line dispatches the event.
A Streaming Endpoint in Node
Here is a complete, working SSE endpoint using Express. It streams a counter and then closes:
import express from "express";
const app = express();
app.get("/events", (req, res) => {
res.writeHead(200, {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
"Connection": "keep-alive",
});
let count = 0;
const timer = setInterval(() => {
count += 1;
res.write(`event: tick\n`);
res.write(`id: ${count}\n`);
res.write(`data: ${JSON.stringify({ count })}\n\n`);
if (count >= 5) {
res.write(`event: done\ndata: {}\n\n`);
clearInterval(timer);
res.end();
}
}, 1000);
// Always clean up when the client disconnects
req.on("close", () => clearInterval(timer));
});
app.listen(3000, () => console.log("SSE server on :3000"));
Two details matter. First, you must disable buffering (Cache-Control: no-cache) or proxies may hold your events. Second, always handle req.on("close") — without it, abandoned connections leak timers and memory.
Consuming It in the Browser
The browser ships with EventSource, which handles parsing and reconnection for you:
const source = new EventSource("/events");
source.addEventListener("tick", (e) => {
const { count } = JSON.parse(e.data);
console.log("tick", count);
});
source.addEventListener("done", () => {
console.log("stream finished");
source.close();
});
source.onerror = (err) => console.error("connection error", err);
If the connection drops, EventSource automatically reconnects and sends a Last-Event-ID header containing the last id it saw — so your server can resume from where it left off.
Consuming It Outside the Browser
EventSource doesn't support custom headers, which is a problem for authenticated APIs. In Node or any backend, use fetch and parse the stream yourself:
const res = await fetch("https://api.example.com/events", {
headers: { Authorization: `Bearer ${token}` },
});
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const events = buffer.split("\n\n");
buffer = events.pop(); // keep the incomplete trailing chunk
for (const block of events) {
const dataLine = block.split("\n").find((l) => l.startsWith("data:"));
if (dataLine) console.log("event", JSON.parse(dataLine.slice(5).trim()));
}
}
This same pattern is exactly how OpenAI-style streaming completions are consumed — they are SSE under the hood.
Gotchas Worth Knowing
Keep connections alive through idle proxies by sending a comment line (: keep-alive\n\n) every 15–30 seconds. Browsers cap SSE to six concurrent connections per domain over HTTP/1.1, so prefer HTTP/2 if clients open many streams. And SSE only carries UTF-8 text — to send binary, base64-encode it.
Testing Streaming Endpoints
Streaming endpoints are harder to test than request/response ones because the output arrives in pieces over time. A good API workspace like APIKumo lets you fire a request at an text/event-stream endpoint and watch events arrive live, inspect each data frame, and save the call alongside the rest of your collection — so verifying that your SSE stream emits the right events becomes a one-click check instead of a custom script. Once your streaming contract is captured there, the whole team can replay it the same way.
Top comments (0)