Server-Sent Events in Production: Real-Time Dashboards, Event Streaming, and Scaling Patterns Beyond WebSockets
A production-focused guide to SSE: protocol mechanics, Node.js/Hono server implementation, EventSource reconnection handling, Redis fan-out for horizontal scaling, backpressure, proxy timeouts, and a decision framework for SSE vs WebSockets.
Most teams reach for WebSockets the moment a feature needs real-time updates. It is the familiar choice, and it works. But WebSockets carry bidirectional overhead: upgraded connections, custom framing, and stateful protocol negotiation. For a large class of features, the server is doing all the talking. Dashboards, live feeds, build logs, notification streams. For these, a plain HTTP response that never closes is enough, and that is what Server-Sent Events (SSE) actually are.
This article covers the protocol mechanics, TypeScript server and client implementation, Redis-based fan-out for horizontal scaling, and the operational concerns that trip teams up when they first deploy SSE at any meaningful scale.
What SSE Actually Is
SSE is not a separate protocol. It is an HTTP response with Content-Type: text/event-stream that the server holds open indefinitely. The browser’s EventSource API manages the connection and handles reconnection automatically. The wire format is plain text.
data: {"orderId":"abc123","status":"shipped"}\n\n
A complete event uses line-oriented fields:
id: 42
event: order-update
data: {"orderId":"abc123","status":"shipped"}
retry: 3000
Each field on its own line, event terminated by a blank line. The id field populates lastEventId on the client. The event field names the event type for addEventListener. The retry field overrides the reconnection delay in milliseconds. The data field can span multiple lines; each continuation line must repeat the data: prefix and the values are joined with a newline.
That is the entire protocol. There is no handshake, no binary framing, no opcodes. This simplicity is both the feature and the limitation.
Server Implementation in Node.js with Hono
Hono is a clean fit for SSE because it gives you direct access to the response object without framework interference. Here is a complete implementation with connection tracking and heartbeats.
import { Hono } from "hono";
import { streamSSE } from "hono/streaming";
type ConnectionRecord = {
id: string;
send: (event: SSEEvent) => Promise<void>;
lastEventId: string | null;
};
type SSEEvent = {
id?: string;
event?: string;
data: unknown;
retry?: number;
};
const connections = new Map<string, ConnectionRecord>();
function formatSSEEvent(event: SSEEvent): string {
const lines: string[] = [];
if (event.id !== undefined) lines.push(`id: ${event.id}`);
if (event.event !== undefined) lines.push(`event: ${event.event}`);
if (event.retry !== undefined) lines.push(`retry: ${event.retry}`);
const payload =
typeof event.data === "string" ? event.data : JSON.stringify(event.data);
lines.push(`data: ${payload}`);
lines.push("");
return lines.join("\n") + "\n";
}
const app = new Hono();
app.get("/events", (c) => {
const connectionId = crypto.randomUUID();
const lastEventId = c.req.header("Last-Event-ID") ?? null;
return streamSSE(c, async (stream) => {
const send = async (event: SSEEvent) => {
await stream.write(formatSSEEvent(event));
};
connections.set(connectionId, { id: connectionId, send, lastEventId });
// Replay missed events if client is reconnecting
if (lastEventId !== null) {
await replayMissedEvents(lastEventId, send);
}
// Heartbeat to keep the connection alive through proxies
const heartbeat = setInterval(async () => {
try {
await stream.write(": heartbeat\n\n");
} catch {
clearInterval(heartbeat);
connections.delete(connectionId);
}
}, 20_000);
stream.onAbort(() => {
clearInterval(heartbeat);
connections.delete(connectionId);
});
// Hold the stream open
await new Promise<void>((resolve) => {
stream.onAbort(resolve);
});
});
});
The ": heartbeat\n\n" line is a comment in SSE syntax. It resets the proxy idle timeout without producing a client-side event. This is the mechanism that keeps connections alive through AWS ALB (60s default), nginx, and Cloudflare (100s default).
stream.onAbort fires when the client disconnects. Without this cleanup, dead connections accumulate in the map until the process restarts.
Client-Side EventSource and Reconnection
The browser’s EventSource API handles reconnection automatically. When the connection drops, it waits for the retry interval (3000ms by default) and reconnects, sending Last-Event-ID as a request header. Your server can use this header to replay missed events.
type DashboardEvent =
| { type: "metrics-snapshot"; payload: MetricsSnapshot }
| { type: "metric-window"; payload: MetricWindow };
function connectToDashboard(
url: string,
onEvent: (event: DashboardEvent) => void
): () => void {
const source = new EventSource(url, { withCredentials: true });
source.addEventListener("metrics-snapshot", (e) => {
onEvent({ type: "metrics-snapshot", payload: JSON.parse(e.data) });
});
source.addEventListener("metric-window", (e) => {
onEvent({ type: "metric-window", payload: JSON.parse(e.data) });
});
source.addEventListener("server-restart", () => {
// Server is about to close the connection intentionally.
// Close our side cleanly to avoid reconnecting immediately
// while the server is still restarting.
source.close();
setTimeout(() => connectToDashboard(url, onEvent), 5_000);
});
source.onerror = (e) => {
console.warn("SSE connection error, EventSource will retry", e);
};
return () => source.close();
}
One thing the automatic reconnection does not handle: if the server was down long enough that your event log has been trimmed, replaying from Last-Event-ID will return nothing or an error. The server needs to detect this case and send a full snapshot instead of an incremental replay. The client should treat a metrics-snapshot event as “replace all state” and a metric-window event as “append.”
SSE only works over HTTP/1.1 or HTTP/2. If you need to send credentials, set withCredentials: true. SSE does not support custom request headers, which matters if you use bearer token authentication. The common workaround is to pass the token as a query parameter (with appropriate server-side validation) or issue a short-lived connection token via a prior authenticated API call.
Building a Real-Time Dashboard
Dashboards have a specific pattern: the server aggregates raw event streams into fixed windows before pushing to clients. Pushing every raw event to every browser tab is wasteful and causes chart flicker.
type MetricWindow = {
windowStart: number;
p50: number;
p95: number;
p99: number;
requestCount: number;
errorRate: number;
};
class MetricsAggregator {
private buffer: number[] = [];
private errors = 0;
private flushInterval: ReturnType<typeof setInterval>;
constructor(
private windowMs: number,
private onFlush: (window: MetricWindow) => void
) {
this.flushInterval = setInterval(() => this.flush(), windowMs);
}
record(latencyMs: number, isError: boolean) {
this.buffer.push(latencyMs);
if (isError) this.errors++;
}
private flush() {
if (this.buffer.length === 0) return;
const sorted = [...this.buffer].sort((a, b) => a - b);
const p = (pct: number) =>
sorted[Math.floor((sorted.length - 1) * pct)] ?? 0;
this.onFlush({
windowStart: Date.now(),
p50: p(0.5),
p95: p(0.95),
p99: p(0.99),
requestCount: this.buffer.length,
errorRate: this.errors / this.buffer.length,
});
this.buffer = [];
this.errors = 0;
}
stop() {
clearInterval(this.flushInterval);
}
}
On the client side, chart libraries re-render on every data update. Coalescing rapid-fire events with requestAnimationFrame avoids unnecessary renders when events arrive faster than the frame rate.
function useSSEMetrics(url: string): MetricWindow[] {
const dataRef = useRef<MetricWindow[]>([]);
const pendingRef = useRef<MetricWindow | null>(null);
const rafRef = useRef<number | null>(null);
const [, forceUpdate] = useState(0);
useEffect(() => {
const disconnect = connectToDashboard(url, (event) => {
if (event.type === "metrics-snapshot") {
dataRef.current = event.payload.windows;
forceUpdate((n) => n + 1);
return;
}
// Deduplicate by windowStart to handle reconnect replay seams
const incoming = event.payload;
const exists = dataRef.current.some(
(w) => w.windowStart === incoming.windowStart
);
if (!exists) {
dataRef.current = [...dataRef.current.slice(-99), incoming];
pendingRef.current = incoming;
}
// Coalesce with rAF
if (rafRef.current === null) {
rafRef.current = requestAnimationFrame(() => {
rafRef.current = null;
forceUpdate((n) => n + 1);
});
}
});
return disconnect;
}, [url]);
return dataRef.current;
}
The deduplication check on windowStart handles the reconnect case where the server replays a few events that the client already received. Without it, you get doubled data points at the seam.
Scaling SSE: The Real Problem
A single Node.js process can comfortably hold a few thousand SSE connections. The problem is horizontal scaling. SSE connections are long-lived and stateful per process. When you add a second instance, new connections land on either instance. To broadcast an event to all connected clients, every instance needs to know about it.
There are two approaches.
Sticky sessions route each client to the same backend instance for the lifetime of the connection. It works, but it creates uneven load distribution and complicates deployments. An instance restart forces all its clients to reconnect and potentially hit a different backend.
Redis pub/sub fan-out is the more robust approach. Each server instance subscribes to the relevant Redis channel. When a publisher pushes an event, every instance receives it and forwards it to its local connections. No session stickiness required.
import { createClient } from "redis";
const publisher = createClient({ url: process.env.REDIS_URL });
const subscriber = createClient({ url: process.env.REDIS_URL });
await publisher.connect();
await subscriber.connect();
// Publisher: push events from your application logic
async function publishMetricWindow(
dashboardId: string,
window: MetricWindow
): Promise<void> {
await publisher.publish(
`dashboard:${dashboardId}`,
JSON.stringify({ event: "metric-window", data: window })
);
}
// Per-instance subscriber setup
const localConnections = new Map<
string,
Map<string, ConnectionRecord>
>();
async function subscribeToChannel(channelPattern: string): Promise<void> {
await subscriber.pSubscribe(channelPattern, (message, channel) => {
const dashboardId = channel.split(":")[1];
const connections = localConnections.get(dashboardId);
if (!connections || connections.size === 0) return;
const parsed = JSON.parse(message) as { event: string; data: unknown };
const ssePayload = formatSSEEvent({
event: parsed.event,
data: parsed.data,
});
for (const conn of connections.values()) {
conn.send({ event: parsed.event, data: parsed.data }).catch(() => {
connections.delete(conn.id);
});
}
});
}
Publisher and subscriber must be separate Redis connections. A connection in subscribe mode cannot issue other commands.
Event Persistence and Replay
The heartbeat and Redis fan-out solve the live delivery problem. Replay requires event persistence. Redis Streams are a natural fit: each event gets an auto-generated ID, and you can query a range with XRANGE.
async function persistEvent(
stream: string,
event: SSEEvent,
maxLen = 1000
): Promise<string> {
const id = await publisher.xAdd(
stream,
"*",
{ event: event.event ?? "message", data: JSON.stringify(event.data) },
{ TRIM: { strategy: "MAXLEN", threshold: maxLen } }
);
return id;
}
async function replayMissedEvents(
lastEventId: string,
send: (event: SSEEvent) => Promise<void>,
stream: string
): Promise<void> {
const events = await publisher.xRange(stream, `(${lastEventId}`, "+");
for (const entry of events) {
await send({
id: entry.id,
event: entry.message.event,
data: JSON.parse(entry.message.data),
});
}
}
The (${lastEventId} syntax is an exclusive range start, so the client does not receive the last event it already processed. If the stream has been trimmed past lastEventId, xRange returns an empty array. That is your signal to send a full snapshot instead.
Proxy Timeouts and HTTP/2
Three configuration problems cause most SSE headaches in production.
nginx buffering. By default, nginx buffers proxy responses. SSE events sit in the buffer until the connection closes. Fix:
location /events {
proxy_pass http://backend;
proxy_buffering off;
proxy_read_timeout 3600s;
proxy_set_header Connection "";
proxy_http_version 1.1;
}
AWS ALB idle timeout. The default is 60 seconds. Your heartbeat interval should be 20-25 seconds, well below this. Alternatively, raise the ALB timeout to 3600 seconds for the SSE target group.
HTTP/2 multiplexing. HTTP/2 allows many streams over a single TCP connection. SSE over HTTP/2 works correctly and is actually more efficient: the browser can open many SSE connections to the same origin without hitting the HTTP/1.1 per-domain connection limit (typically 6). If you are still on HTTP/1.1 and a single page opens multiple SSE streams, you will hit that limit fast. HTTP/2 eliminates it.
Backpressure and Connection Limits
SSE does not have a built-in flow control mechanism. If your server produces events faster than the client can consume them, the TCP send buffer fills up, writes block, and you need a strategy.
async function sendWithTimeout(
conn: ConnectionRecord,
event: SSEEvent,
timeoutMs = 5_000
): Promise<boolean> {
try {
await Promise.race([
conn.send(event),
new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error("send timeout")), timeoutMs)
),
]);
return true;
} catch {
// Client is not consuming fast enough or disconnected
connections.delete(conn.id);
return false;
}
}
For dashboards, the aggregation window approach (5-second windows rather than raw events) is the practical answer: you cap the event rate by design. For event streams with variable throughput, you can drop events for slow consumers or maintain a per-connection buffer with a maximum depth, evicting the oldest events when the buffer fills.
File descriptor limits are the other constraint. Each SSE connection consumes one FD. The Linux default is 1024 per process. At 900 connections you will start getting EMFILE errors. Before deploying, set:
ulimit -n 65536
In a systemd unit:
[Service]
LimitNOFILE=65536
SSE vs WebSockets: Decision Framework
| Criterion | SSE | WebSockets |
|---|---|---|
| Direction | Server to client only | Bidirectional |
| Protocol | HTTP | Upgraded HTTP (ws://) |
| Reconnection | Automatic (EventSource) | Manual |
| Proxy support | Works through any HTTP proxy | Requires proxy upgrade support |
| HTTP/2 multiplexing | Yes | No (one TCP per connection) |
| Authentication | Headers on initial request | Headers on initial request |
| Binary data | No (text only) | Yes |
| Load balancer complexity | Sticky sessions or pub/sub fan-out | Same |
| Browser support | All modern browsers | All modern browsers |
| Server libraries | Any HTTP framework | Dedicated library |
Use SSE when: the server is the only sender, you want automatic reconnection with event replay, you are behind an HTTP-only proxy, or you are already in an HTTP/2 deployment and want multiplexing for free.
Use WebSockets when: the client needs to send data on the same connection (collaborative editing, game input, voice/video signaling), you need binary frames without base64 encoding, or you have an existing WebSocket infrastructure.
The cases where SSE is the wrong choice are narrower than most teams assume. Dashboards, notification feeds, build logs, deployment status streams, live search suggestions from a streaming LLM: all of these are server-to-client flows. SSE handles them with less infrastructure than WebSockets require.
Graceful Shutdown
When you deploy a new version, you need to close SSE connections cleanly. Abrupt process termination forces all clients to reconnect simultaneously, which is a thundering herd.
async function gracefulShutdown(signal: string): Promise<void> {
console.log(`${signal} received, closing SSE connections`);
// Notify clients to wait before reconnecting
const shutdownEvent = formatSSEEvent({
event: "server-restart",
data: { retryAfterMs: 5_000 },
retry: 5_000,
});
const sends = Array.from(connections.values()).map((conn) =>
conn.send({ event: "server-restart", data: { retryAfterMs: 5_000 }, retry: 5_000 })
.catch(() => undefined)
);
await Promise.allSettled(sends);
// Give clients a moment to receive the event
await new Promise((resolve) => setTimeout(resolve, 500));
process.exit(0);
}
process.on("SIGTERM", () => gracefulShutdown("SIGTERM"));
process.on("SIGINT", () => gracefulShutdown("SIGINT"));
The retry: 5000 in the shutdown event overrides the client’s reconnection delay for subsequent reconnects. After the restart, clients come back gradually rather than all at once.
Production Checklist
The operational concerns cluster around a few recurring issues:
FD limits. Set ulimit -n 65536 before you need it. You will not notice the problem until you are past 1000 concurrent connections in production.
Proxy buffering. Always set proxy_buffering off on nginx for SSE routes. Missing this causes events to batch and deliver on connection close, which defeats the purpose.
Heartbeat interval. Keep it below your shortest proxy idle timeout. 20 seconds is safe for most configurations.
Horizontal scaling. Sticky sessions work but add operational complexity. Redis pub/sub fan-out scales more cleanly and survives rolling deployments without dropping events.
Event persistence. If your stream has any reconnection scenarios (and it will), you need an event log to replay from. Redis Streams with MAXLEN trimming keeps memory bounded.
Graceful shutdown. Send a server-restart event before killing the process. Set retry high enough to spread the reconnect load.
SSE is, at its core, a consequence of holding an HTTP response open longer than usual. The protocol is trivial. The operational complexity sits in the infrastructure around it: load balancer configuration, proxy behavior, FD limits, and event fan-out. Once those concerns are addressed, SSE is a reliable, low-overhead mechanism for server-to-client streaming that most teams underuse in favor of WebSockets they do not actually need.
More in Web Engineering
How Rspack Works Internally: Webpack-Compatible Module Graph, Rust Compilation Pipeline, and the Incremental Build Architecture Behind the Fastest Webpack Replacement
A deep dive into Rspack's Rust-based architecture, module graph construction, SWC transformation pipeline, webpack compatibility layer, incremental compilation, and what the tradeoffs look like for teams migrating from webpack.
How Turbopack Works Internally: Incremental Computation, Function-Level Caching, and the Rust-Based Bundler Architecture Behind Next.js
A deep dive into Turbopack's architecture covering the Turbo engine's incremental computation model, function-level caching, Rust-based module resolution, granular HMR invalidation, SWC integration, persistent caching, and how it compares to webpack, Vite, esbuild, and Rspack.
How gRPC Works: Protocol Buffers, HTTP/2 Multiplexing, and Bidirectional Streaming From Service Definition to Wire Format
A deep dive into gRPC internals: Protocol Buffer IDL and the buf codegen pipeline, HTTP/2 stream multiplexing and HPACK header compression, all four RPC patterns with TypeScript, channel management, deadline propagation, interceptors, load balancing from pick-first to xDS, the health checking protocol, and production tradeoffs vs REST and GraphQL.
How Node.js Works Internally: The Event Loop, libuv Thread Pool, and Async I/O Architecture From require() to Process Exit
A deep dive into Node.js internals covering V8 JIT compilation, the six-phase libuv event loop, thread pool mechanics, microtask queue priority, async/await desugaring, CommonJS versus ESM module resolution, and production tuning considerations with TypeScript examples.