For a long time, the conversation about "real-time updates in the browser" defaulted to WebSockets. WebSockets are a great tool when both sides of the connection need to send messages frequently, but a large fraction of real-time UIs only need updates flowing in one direction: server to client. Live dashboards. Notification feeds. Progress indicators for a long-running job. For all of these, Server-Sent Events are a lighter, simpler primitive that lives entirely on top of plain HTTP and works through every proxy that already speaks HTTP correctly.
This guide walks through what SSE actually is, how to implement the server and client sides, and the production gotchas that decide whether the system stays reliable when traffic grows.

Photo by Brett Sayles on Pexels
What Server-Sent Events actually are
A Server-Sent Events stream is a single long-lived HTTP response with Content-Type: text/event-stream. The server opens the response, writes one or more event records to the body, and keeps the connection open. The client receives those records as they arrive and parses them with a small, browser-native API called EventSource.
The protocol is line-oriented and trivially small. An event record looks like this:
event: notification
data: {"id":42,"text":"build complete"}
id: 42
A blank line terminates the record. Each field is optional. The event field names the event type (clients can subscribe to specific names). The data field carries the payload, which is conventionally JSON but can be any UTF-8 text. The id field, if present, lets the client resume the stream from a known point after a disconnection by sending a Last-Event-ID header on reconnect.
That is roughly the entire specification, captured in the WHATWG HTML standard under the EventSource section. There is no handshake. There is no special framing. There is no separate transport. SSE is just HTTP held open with a specific content type and a specific line format.
When SSE is the right tool
SSE is the right tool when the data flows one way and the client only needs to receive. The browser-native EventSource API does automatic reconnection, handles resumption with Last-Event-ID, and works through every CDN, load balancer, and corporate proxy that handles long-lived HTTP responses correctly. For a notification feed, a live metric dashboard, or a build-progress UI, the operational simplicity is genuinely valuable.
SSE is the wrong tool when the client also needs to send frequent updates back to the server. WebSockets are bidirectional by design, and forcing a WebSocket-shaped workload through SSE plus a separate POST endpoint produces an awkward two-channel design. The longer you spend trying to bend SSE into a bidirectional shape, the louder the design is telling you to use WebSockets instead.
SSE is also the wrong tool when the payload is binary. The protocol is text-only. If you are streaming images, audio, or other binary content, SSE is not the layer to use. For binary streaming, look at HTTP/2 server push or a chunked binary endpoint instead.
The minimum-viable server implementation
A working SSE endpoint on a Node.js HTTP server is roughly twenty lines.
import http from 'node:http';
const subscribers = new Set();
const server = http.createServer((req, res) => {
if (req.url !== '/events') {
res.writeHead(404);
res.end();
return;
}
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
});
res.write(': hello\n\n');
subscribers.add(res);
req.on('close', () => subscribers.delete(res));
});
function broadcast(eventName, payload) {
const line = `event: ${eventName}\ndata: ${JSON.stringify(payload)}\n\n`;
for (const res of subscribers) res.write(line);
}
server.listen(3000);
The handler sets three headers (the content type, a no-cache directive, and an explicit keep-alive) and writes a comment line (: hello\n\n) immediately to flush the response headers through any reverse proxy that buffers until first content.
The subscribers set tracks open responses. The broadcast function writes the same event line to every subscriber, which is fine for small fleets but does not scale across multiple Node processes. For multi-process scale, broadcasts have to go through a shared bus (Redis pub/sub, a message broker, or any equivalent fan-out mechanism documented at Node.js). Each Node process subscribes to the bus, receives broadcasts, and writes them to its locally-connected subscribers.
The other clean way to author SSE on Node is through a framework that already handles the response lifecycle. Both Express and Fastify accept a streaming response handler and let you write the same lines without touching the raw HTTP server, which is helpful when the SSE endpoint lives alongside other application routes.

Photo by Brett Sayles on Pexels
The minimum-viable client implementation
The browser side is a single line plus event handlers.
const source = new EventSource('/events');
source.addEventListener('notification', (e) => {
const payload = JSON.parse(e.data);
console.log('got notification', payload);
});
source.addEventListener('error', (e) => {
console.warn('stream error, will auto-reconnect', e);
});
EventSource is a built-in browser API. It opens the connection, subscribes to events by name (notification in this case), and reconnects automatically if the connection drops. The default reconnection backoff is implementation-defined, but the spec lets the server send a retry: line to override it.
If you set the id: field on each event from the server, the browser's EventSource automatically sends Last-Event-ID: <last-seen-id> on reconnect. The server can use that header to replay events the client missed. This is the cheapest durable-stream primitive in the browser, and it is the feature that makes SSE genuinely competitive with WebSockets for one-way notifications.
For details on the EventSource API surface, the MDN web docs cover the supported events, properties, and browser support. SSE is supported in every modern browser; the EventSource polyfills that existed for older IE versions are no longer needed in 2026.
The five production gotchas
What separates a demo from a system you can trust at scale is awareness of where SSE breaks. Five gotchas appear in roughly every real deployment.
Gotcha 1: reverse-proxy buffering. Some reverse proxies (older nginx configurations, certain CDN edge tiers, some corporate proxies) buffer the response body until they see a content-length or hit a threshold. With SSE there is no content-length and the threshold may never be reached, which means events sit in the proxy until the timeout closes the connection. Fix: send a comment line and flush immediately on connection open (the : hello\n\n in the example above), set X-Accel-Buffering: no for nginx, and confirm the CDN config disables buffering for the SSE path.
Gotcha 2: server-side connection caps. Long-lived connections consume file descriptors, memory, and (in some configurations) thread slots. A server tuned for short request-response cycles will fall over at a few thousand SSE connections. Fix: tune the file-descriptor limit, the worker model (an event-loop server like Node, Go, or Rust handles SSE far better than a one-thread-per-connection server like PHP-FPM), and the load balancer's idle timeout. Document the per-process connection budget and plan capacity around it.
Gotcha 3: client-side disconnection silence. A client behind a flaky network can disconnect without ever sending a TCP FIN that the server sees. The server keeps the response object around, broadcasts to a dead socket, and only discovers the death on the next write that fails. Fix: send a periodic comment-line heartbeat (: ping\n\n every 15-30 seconds) and use TCP keepalive at the OS level. Both help the server notice dead connections promptly.
Gotcha 4: the missing-event problem. Even with Last-Event-ID, a server that does not retain events long enough to serve a reconnecting client will lose data. The same applies if the server is stateless and the broker the events came from has already evicted them. Fix: decide a replay window (often "the last sixty seconds" or "the last hundred events") and back the replay path with a store that holds at least that much.
Gotcha 5: authentication. SSE requests are GET requests, and the browser's EventSource constructor does not let you set arbitrary headers. Bearer-token auth in an Authorization header is therefore not directly supported. Workarounds: use cookie-based session authentication (the browser sends cookies automatically), or pass the token as a query parameter (less ideal because it can show up in logs). For cross-origin streams, set CORS headers correctly and use withCredentials: true on the EventSource constructor.

Photo by K on Pexels
"We have seen more SSE deployments stalled by reverse-proxy buffering than by any other single cause. The fix is a one-line config change, but you have to know to look for it. The symptom is a stream that connects, says nothing for thirty seconds, then disconnects." - Dennis Traina, founder of 137Foundry

Photo by Marek Piwnicki on Pexels
Scaling beyond a single server
The Node example above is a single-process broadcaster. Real deployments usually have several application processes behind a load balancer, with each process holding a subset of the active subscribers.
The standard scaling pattern is to put a message bus (Redis pub/sub is the canonical choice; any equivalent broker works) between the event source and the application processes. The event source publishes once to the bus. Every application process subscribes to the bus. When a process receives a message from the bus, it writes the event line to every locally-connected subscriber. The bus handles the fan-out across processes; each process handles the fan-out across its local subscribers.
This pattern composes cleanly. You can run any number of application processes behind any sticky load balancer (or a non-sticky one; SSE does not require sticky sessions). The bus is the single coordination point. The number of subscribers per process is bounded by the OS's file-descriptor limit and the per-process memory budget, both of which are easy to tune.
For the heaviest deployments, a dedicated SSE gateway (a small Go or Rust service that holds the connections and subscribes to the bus) can serve more connections per node than a general-purpose application server can. This is usually only worth doing once you are past tens of thousands of concurrent connections per node; for smaller fleets, sticking with the application server keeps the operational surface area small.
Where SSE fits in the broader real-time toolkit
The cleanest mental model is to think of three tiers of real-time transport in the browser.
Polling. The client asks "anything new?" on an interval. Simple, scales horizontally without effort, wastes bandwidth and latency. The right answer when updates are rare or the worst-case staleness is fine.
Server-Sent Events. A long-lived HTTP response from server to client. The right answer when updates flow one way, can be text, and the underlying HTTP infrastructure is healthy.
WebSockets. A full-duplex connection over a single TCP socket. The right answer when both sides talk frequently, the payload may be binary, or you need bidirectional flow control.
For most "show new things as they appear" UIs in business applications, SSE is the right pick. The implementation is small, the operational model is simple, and the protocol works through every HTTP middlebox already in your stack. The cases where you need a WebSocket are the cases where the client genuinely needs to send as much as it receives. If you find your design pushing in that direction, switch transports rather than fighting SSE.
Teams building these systems for the first time often benefit from working with an experienced web engineering partner on the scaling-out path: the fan-out, the replay window, the proxy configuration. The protocol is small; the production gotchas around it are where most of the lessons live. The 137Foundry services hub is the place to start for that kind of engagement, and the dedicated web development service page covers the practical scope.
Putting it together
SSE is one of the most underused tools in the modern web platform. It is built into every browser, it speaks plain HTTP, and the protocol is small enough to read in fifteen minutes. The hard parts are not the syntax but the production discipline around it: heartbeats, proxy configuration, replay windows, connection budgets, authentication. None of those are exotic. All of them have to be in place before the system can be trusted at scale.
If you have a UI showing live data and you have been reaching for WebSockets out of habit, take a fresh look at SSE. For one-way flow, the simplicity is hard to beat, and the operational story over the years has been remarkably stable.