Server-Sent Events in Production: Real-Time Updates Without WebSocket Complexity
SSE is a one-way, HTTP-native push protocol that handles 80% of real-time use cases with a fraction of the operational overhead of WebSockets. This guide covers the protocol, TypeScript implementation, reconnection mechanics, load balancer configuration, CDN behavior, and fan-out patterns for multi-user scenarios.
Most teams reach for WebSockets the moment they need to push data to a browser. That decision carries real costs: the WebSocket upgrade handshake requires sticky sessions or a shared pub/sub layer from day one, proxy and CDN support is inconsistent, and debugging a ws:// connection in a network tab is significantly worse than an HTTP request.
Server-Sent Events (SSE) solve a narrower problem and do it cleanly. If your server needs to push a stream of updates to clients and those clients never need to send messages back on the same connection, SSE is the right choice. It runs over plain HTTP/1.1 or HTTP/2, the browser handles reconnection automatically, and every proxy, load balancer, and CDN that understands HTTP already understands it.
This guide covers the protocol itself, a production-grade TypeScript implementation, the reconnection contract, infrastructure considerations, and fan-out patterns when you need to push the same event to thousands of connections.
The Protocol
SSE is an HTTP response that never ends. The server sets Content-Type: text/event-stream and streams newline-delimited text frames. Each frame looks like this:
id: 42
event: order-update
data: {"orderId":"ord_123","status":"shipped"}
The blank line terminates the event. The fields are:
id: a string the browser uses asLast-Event-IDon reconnect. The server uses this to replay missed events.event: an arbitrary event type. The client listens withaddEventListener('order-update', ...). If omitted, the default ismessage.data: the payload. Multi-line payloads use multipledata:lines; the browser joins them with\n.retry: a number (in milliseconds) that overrides the browser’s reconnect interval. Useful for backpressure signaling.
Keep-alive comments (": heartbeat", a line starting with :) are used to prevent proxies from closing idle connections. Send one every 15-20 seconds.
SSE vs. WebSockets vs. Long Polling
The choice is usually between three options. Here is where each one breaks down.
| Dimension | SSE | WebSockets | Long Polling |
|---|---|---|---|
| Direction | Server to client only | Bidirectional | Server to client only |
| Protocol | HTTP/1.1 or HTTP/2 | TCP upgrade | HTTP |
| Browser reconnect | Built-in, automatic | Manual | Manual |
| Proxy/CDN support | Broad (HTTP-native) | Inconsistent | Broad |
| Sticky sessions needed | Yes (HTTP/1.1), No (HTTP/2) | Yes | No |
| Connection limit per origin | 6 (HTTP/1.1), unlimited (HTTP/2) | Unlimited | Unlimited |
| Multiplexing | HTTP/2 only | No | No |
| Debugging | Network tab, readable | Opaque frames | Network tab |
| Implementation complexity | Low | Medium | Low |
| Good for | Feeds, notifications, progress | Chat, games, collaborative editing | Legacy environments |
The browser limit of 6 concurrent HTTP/1.1 SSE connections per origin is the most common reason teams abandon SSE prematurely. With HTTP/2, this limit disappears entirely because connections are multiplexed. Verify that your server and CDN both negotiate HTTP/2 before dismissing SSE on this basis.
WebSockets win when the client genuinely needs to send messages at high frequency on the same connection: a multiplayer game, a collaborative text editor, a trading terminal. For the majority of real-time features in a typical web app (notifications, live dashboards, deployment logs, background job progress), the server is doing all the talking and SSE is strictly simpler.
Long polling is the fallback for environments where streaming HTTP responses are not reliably supported, such as some older corporate proxies. Implementing it correctly with proper backpressure is more work than either SSE or WebSockets.
Server Implementation in TypeScript
The following example uses Hono, but the pattern translates directly to any Node.js HTTP framework. The key is writing to a Response body stream backed by a ReadableStream.
import { Hono } from "hono";
import { streamSSE } from "hono/streaming";
const app = new Hono();
// In-memory registry of active connections per topic.
// Replace with Redis pub/sub for multi-instance deployments.
const connections = new Map<string, Set<(event: SSEEvent) => void>>();
interface SSEEvent {
id: string;
event: string;
data: unknown;
}
function registerConnection(
topic: string,
send: (event: SSEEvent) => void
): () => void {
if (!connections.has(topic)) {
connections.set(topic, new Set());
}
connections.get(topic)!.add(send);
return () => {
connections.get(topic)?.delete(send);
if (connections.get(topic)?.size === 0) {
connections.delete(topic);
}
};
}
function formatSSE(event: SSEEvent): string {
const lines: string[] = [];
lines.push(`id: ${event.id}`);
lines.push(`event: ${event.event}`);
lines.push(`data: ${JSON.stringify(event.data)}`);
lines.push(""); // blank line terminates event
return lines.join("\n") + "\n";
}
app.get("/stream/:topic", async (c) => {
const topic = c.req.param("topic");
const lastEventId = c.req.header("Last-Event-ID");
// Authenticate here. SSE connections are long-lived; validate on connect.
const userId = c.req.header("Authorization"); // simplified
if (!userId) {
return c.text("Unauthorized", 401);
}
return streamSSE(c, async (stream) => {
// Replay missed events if the client reconnected with a Last-Event-ID.
if (lastEventId) {
const missed = await getMissedEvents(topic, lastEventId);
for (const event of missed) {
await stream.writeSSE({
id: event.id,
event: event.event,
data: JSON.stringify(event.data),
});
}
}
// Send initial retry interval to clients (15 seconds).
await stream.write(`retry: 15000\n\n`);
const send = async (event: SSEEvent) => {
await stream.writeSSE({
id: event.id,
event: event.event,
data: JSON.stringify(event.data),
});
};
const unregister = registerConnection(topic, send);
// Heartbeat to keep proxies alive.
const heartbeat = setInterval(async () => {
await stream.write(": heartbeat\n\n");
}, 20_000);
// Clean up when the client disconnects.
stream.onAbort(() => {
unregister();
clearInterval(heartbeat);
});
// Hold the stream open.
await stream.sleep(Number.MAX_SAFE_INTEGER);
});
});
// Stub: retrieve events from a durable store after a given event ID.
async function getMissedEvents(
topic: string,
afterId: string
): Promise<SSEEvent[]> {
// Query your database for events with id > afterId for this topic.
return [];
}
export default app;
A few things worth noting here:
The registerConnection function is a simple in-process registry. It works for a single server instance. The moment you have more than one server, you need a fan-out mechanism, which the last section covers.
Authentication happens once on connection, not per event. SSE is a single HTTP request, so any middleware or header-based auth that works for a normal request works here too. The tricky part is that cookies are included automatically, but Authorization headers are not, because EventSource in the browser does not support custom headers. If you rely on bearer tokens, pass them as a query parameter or switch to a cookie-based session.
The Last-Event-ID header is sent by the browser on every reconnect attempt, using the id of the last event it received. Your server needs to store events durably (in a database or a Redis stream with a short TTL) to replay them. Without this, reconnecting clients will miss events silently.
Client-Side EventSource Handling
The browser’s EventSource API is straightforward but has a few rough edges.
interface OrderUpdate {
orderId: string;
status: string;
updatedAt: string;
}
function connectToStream(topic: string, token: string) {
// Note: EventSource does not support custom headers.
// Pass auth as a query param or use cookies.
const url = new URL(`/stream/${topic}`, window.location.origin);
url.searchParams.set("token", token);
const source = new EventSource(url.toString());
source.addEventListener("order-update", (event: MessageEvent) => {
const update = JSON.parse(event.data) as OrderUpdate;
handleOrderUpdate(update);
});
source.addEventListener("error", (event) => {
// EventSource does not expose error details by design.
// readyState 2 means the connection is closed and will not reconnect.
if (source.readyState === EventSource.CLOSED) {
console.error("SSE connection closed permanently");
// Reconnect logic or show a user-facing error.
}
// readyState 0 (CONNECTING) means the browser is already retrying.
});
source.addEventListener("open", () => {
console.log("SSE connection established");
});
return () => source.close();
}
function handleOrderUpdate(update: OrderUpdate) {
// Handle the update in your application state.
}
The error event is fired on any connection failure, including temporary network drops. The browser reconnects automatically using the retry interval (default 3 seconds, overridable by the server). You do not need to implement reconnection logic yourself, but you do need to handle the case where the readyState reaches CLOSED, which means the server explicitly closed the connection with a non-2xx status or returned a final response. A 204 No Content response will close the connection without triggering a reconnect.
One gotcha: if your server sends a Content-Type other than text/event-stream, the browser closes the connection immediately and fires an error event. This often happens when a reverse proxy returns an HTML error page for a 502.
Reconnection and Last-Event-ID
The reconnection contract works like this:
- The server assigns a monotonically increasing
idto each event and stores events durably for a short window (typically 60-300 seconds depending on your reconnect tolerance). - The client stores the last received
idin memory. TheEventSourceAPI does this automatically. - On reconnect, the browser sends
Last-Event-ID: <last-received-id>as a request header. - The server queries for events after that ID for the given topic and replays them before resuming the live stream.
The durable event store does not need to be complex. A Redis stream with a MAXLEN or a Postgres table with a cleanup job on a short TTL covers most cases. What matters is that the replay query is indexed on (topic, event_id) and the id values are globally ordered within a topic, not just unique.
If events are high-frequency (hundreds per second), you may not want to store every event individually. Consider storing only state snapshots instead: on reconnect, send the current state as a single synthetic event rather than replaying a firehose.
Load Balancers and Connection Limits
SSE connections are long-lived HTTP requests. Load balancers need two things:
Disable response buffering. Most load balancers and reverse proxies buffer responses by default. With SSE, you need to disable this so events flow through immediately rather than accumulating in a buffer until it flushes.
For nginx, add to the upstream location block:
location /stream/ {
proxy_pass http://backend;
proxy_buffering off;
proxy_cache off;
proxy_set_header Connection '';
proxy_http_version 1.1;
chunked_transfer_encoding on;
# Prevent nginx from closing idle connections.
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
For AWS ALB, set the response_streaming_enabled attribute on the target group and ensure your Lambda or ECS task uses the streaming response format. ALB has a 60-second idle timeout by default; the heartbeat comment solves this, but you also need to increase the timeout for connections that legitimately idle (no events, no heartbeat) longer than 60 seconds.
Sticky sessions for HTTP/1.1. Each SSE connection holds state in memory on the server that accepted it (the registry of active connections for a topic). If a client reconnects and lands on a different server, that server will not have the in-memory state. Under HTTP/1.1, use sticky sessions so reconnects return to the same backend. Under HTTP/2, the browser reuses a single multiplexed connection to the same server, so sticky sessions happen naturally.
For production deployments with more than two instances, moving the connection registry out of process is more reliable than relying on sticky sessions. See the fan-out section below.
SSE Through CDNs and Proxies
CDNs vary significantly in their support for streaming responses.
Cloudflare supports SSE, but only if you disable Rocket Loader for the route and set Cache-Control: no-store on the response. Cloudflare’s default caching will otherwise buffer the response. The Transfer-Encoding: chunked header is stripped at the edge when using HTTP/2, which is fine because HTTP/2 has its own framing, but it means you should not depend on chunked encoding signaling for SSE detection.
Some CDNs cache responses aggressively and will serve a stale, already-ended SSE stream to a second client hitting the same URL. Always include Cache-Control: no-store, no-cache on SSE responses, and include the user or session identifier in the URL path or query string so each connection is a unique cache key.
HTTP/2 push (now deprecated in most browsers) is not the same as SSE. Do not confuse them.
One reliable pattern for CDN compatibility: route SSE connections directly to your origin, bypassing the CDN edge entirely, using a separate subdomain (stream.yourdomain.com) that is not behind the CDN. This trades CDN coverage for simplicity and is often worth it when your SSE clients are authenticated and not geographically distributed.
Fan-Out Patterns
The in-process registry works for a single server, but fails the moment you scale horizontally. You need to deliver an event to all connections subscribed to a topic, regardless of which server they are connected to.
The standard approach is a Redis pub/sub layer:
import Redis from "ioredis";
const publisher = new Redis(process.env.REDIS_URL!);
const subscriber = new Redis(process.env.REDIS_URL!);
// On each server instance: subscribe to topics that have active connections.
async function subscribeToTopic(topic: string) {
await subscriber.subscribe(`sse:${topic}`);
}
subscriber.on("message", (channel: string, message: string) => {
const topic = channel.replace("sse:", "");
const event = JSON.parse(message) as SSEEvent;
// Deliver to all connections on this server instance for this topic.
const senders = connections.get(topic);
if (senders) {
for (const send of senders) {
send(event);
}
}
});
// Call this when you want to push an event to all subscribers of a topic.
async function publishEvent(topic: string, event: SSEEvent) {
// Store for replay.
await persistEvent(topic, event);
// Fan out to all server instances.
await publisher.publish(`sse:${topic}`, JSON.stringify(event));
}
async function persistEvent(topic: string, event: SSEEvent): Promise<void> {
// Store in Redis stream or database for Last-Event-ID replay.
await publisher.xadd(
`events:${topic}`,
"MAXLEN",
"~",
1000,
"*",
"payload",
JSON.stringify(event)
);
}
Each server subscribes to topics that have active local connections. When an event is published, all servers receive it via pub/sub and deliver it to their local connections for that topic. The publisher and subscriber should be separate Redis connections because a connection in subscribe mode cannot send other commands.
For very high fan-out scenarios (one event to tens of thousands of connections), Redis pub/sub becomes a bottleneck because each server must deliver the event to all its local connections synchronously in the message handler. Consider batching deliveries with setImmediate or moving to a purpose-built fan-out system that can shard connections across workers.
A second fan-out pattern, better suited to per-user or per-session topics rather than shared broadcast topics, is to store the connection reference in Redis itself using a short TTL and route events by user ID. This is more complex but allows you to publish directly to a user without knowing which server they are connected to.
Production Considerations
Connection count limits. Each SSE connection is an open file descriptor and a socket. A Node.js process defaults to 1024 open file descriptors; raise the ulimit -n on your server to at least 65536 before running anything at scale. At 10,000 concurrent connections, even idle connections consume memory for the socket buffer (roughly 4-8KB per connection on Linux).
Graceful shutdown. On deploy, your process manager will send SIGTERM. You need to drain connections before exiting: close each SSE stream with a final event (event: server-restart, data: {}), which clients can use to trigger a reconnect after a short delay. A 5-10 second drain window is sufficient for most deployments.
process.on("SIGTERM", async () => {
// Notify all connected clients.
for (const [topic, senders] of connections.entries()) {
for (const send of senders) {
send({ id: "shutdown", event: "server-restart", data: { retry: 5000 } });
}
}
// Wait for clients to disconnect.
await new Promise((resolve) => setTimeout(resolve, 5000));
process.exit(0);
});
Observability. Track: active connection count per topic, event delivery latency (time from publish to write), connection duration distribution, and reconnection rate. A high reconnection rate (above 5% per hour) usually indicates a proxy timeout issue, a heartbeat that is too slow, or a server that is crashing under load. Connection duration distribution is useful for detecting resource leaks: a connection that has been open for 24 hours without a heartbeat response is likely a zombie.
Backpressure. If a slow client cannot consume events as fast as the server produces them, the write buffer fills. Node.js streams have a highWaterMark (default 16KB) and will signal backpressure via the return value of write(). If you ignore backpressure, you will eventually exhaust memory for clients that fall behind. The simplest mitigation is to close a connection that has not consumed an event within a threshold (say, 30 seconds) and let it reconnect with Last-Event-ID to catch up.
Choosing SSE vs. WebSockets
Start here:
- Is your real-time feature server-push only (notifications, feeds, progress bars, live metrics)? Use SSE.
- Do you need the client to send messages on the same low-latency connection (collaborative editing, multiplayer, chat)? Use WebSockets.
- Are you behind a proxy or CDN that you cannot control and that does not reliably support streaming? Use long polling.
- Do you have a significant portion of users on HTTP/2? SSE multiplexes for free; the 6-connection limit disappears.
- Are you deploying to an environment with full-request buffering (some serverless platforms)? SSE will not work; use WebSockets or polling.
The operational overhead between SSE and WebSockets is significant over time. WebSockets need a separate upgrade path, a custom framing protocol for anything beyond raw messages, and application-level heartbeats. SSE gets all of this from the HTTP spec and the browser’s EventSource implementation.
The right mental model: SSE is a long-lived HTTP response that streams events. Every HTTP-aware piece of infrastructure already handles it. You are not introducing a new protocol; you are using an existing one in an unconventional way that is nonetheless well-defined and widely supported.
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.