Back to homepage

Server-Sent Events in Node.js: The Underrated Real-Time Solution

· 8 min read ·
nodejs sse real-time javascript

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.

The HTML Living Standard

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

Pros
  • 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
Cons
  • 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)
Tip

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-stream
Connection: keep-alive
Cache-Control: no-cache

Message format:

event: eventName\n
data: your payload here\n
id: optional-id\n
retry: 3000\n
\n

A double newline (\n\n) signals the end of a message. Only the data field is required — everything else is optional.

FieldPurpose
dataThe message payload (required)
eventCustom event name (defaults to "message")
idEvent ID for reconnection resume via Last-Event-ID
retryReconnection 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" events
source.onmessage = (event) => {
const data = JSON.parse(event.data);
console.log("Received:", data);
};
// Named events
source.addEventListener("notification", (event) => {
console.log("Notification:", JSON.parse(event.data));
});
// Error handling
source.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 ID
let 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`);
}
// Usage
sendEvent(res, "notification", {
type: "info",
text: "Deployment complete",
});
sendEvent(res, "metric", {
cpu: 42.5,
memory: 68.2,
});
// Client-side: listening for named events
source.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...
});
Warning

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();
});
// Client
const 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;
};
Note

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" });
Warning

When using fetch instead of EventSource, you lose auto-reconnect and Last-Event-ID. You’ll need to implement those yourself.

Common Gotchas

Note

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.

Warning

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.

Tip

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

MDN: Server-Sent Events
The official MDN documentation covering the EventSource API and the SSE protocol.
developer.mozilla.org
HTML Spec: Server-Sent Events
The living standard specification for the EventSource interface.
html.spec.whatwg.org
Hono SSE Helper
Built-in SSE streaming helper in the Hono framework.
hono.dev

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.

Back to homepage

# // CONTENTS