Skip to main content

Command Palette

Search for a command to run...

The Quiet Channel: A Story About Server-Sent Events

Updated
12 min read

How a forgotten HTTP feature became the silent backbone of real-time software


Prologue: The Event That Never Arrived

Picture this.

A user opens a web app on a Monday morning. Somewhere across the internet, someone on the other end triggers an action — a request comes in, a session is created, a channel is born. The backend springs to life and fires a real-time event into the void.

But the first user's socket wasn't open yet. They'd just logged in. The WebSocket handshake was still finishing.

The event hit — and disappeared. No notification. No update. No response.

They waited a few minutes. Then refreshed. Then gave up.

This is not a hypothetical. It is a class of bug that has quietly broken countless real-time systems — notification platforms, collaboration tools, live dashboards, anything that depends on the server reaching out the moment something happens. And it is exactly the problem that Server-Sent Events — SSE — was built to solve.


Part One: What SSE Actually Is

Before we talk about what SSE does, let's talk about what it is, because most developers have a slightly wrong mental model of it.

SSE is not a new protocol. It is not a library. It is not a framework. It is a feature of HTTP — specifically, a way to keep an HTTP response open indefinitely and push data down it, line by line, as events happen.

That's it. That's the whole thing.

When your browser makes a regular GET /api/data request, the server responds with a body and closes the connection. Done. SSE says: what if we never close it? The server holds the connection open and drips events down the wire whenever it has something to say.

The format is almost comically simple:

data: {"status": "pending", "jobId": "abc-123"}

event: job_complete
data: {"jobId": "abc-123", "result": "success"}

: heartbeat

Each event is a few lines of plain text. A blank line separates events. The browser's built-in EventSource API handles reconnections automatically. There is no handshake beyond the initial HTTP request. No special headers beyond Content-Type: text/event-stream.

It is the most boring, elegant, underrated thing in web development.


Part Two: The Problem It Was Born to Solve

The web was designed for request-response. A browser asks. A server answers. Done.

But somewhere around 2005, the web wanted to do something more ambitious: push. Stock tickers. Chat messages. Live scores. Notifications. The server needed to speak first.

The first hack was polling: the client asks "anything new?" every few seconds. It works, technically. It is also wasteful, laggy, and embarrassing at scale.

The second hack was long-polling: the client asks, the server holds the response open until something happens, then closes it, then the client asks again immediately. Better, but still a cycle of teardown and reconnect, with all the overhead that implies.

Then came WebSockets — a full-duplex upgrade over HTTP. The browser and server negotiate a protocol switch, and suddenly both sides can talk freely. Powerful. But also heavy: a new protocol, new infrastructure considerations, stateful connections that need to be carefully managed, and an entirely different mental model.

And then someone noticed: if all you need is the server to talk to the client, there's a much simpler path. You don't need a bidirectional channel. You need a one-way stream.

SSE was standardized in the HTML5 era (2006, formally in the WHATWG spec) as exactly that: a simple, native browser API for receiving a stream of events from a server over a persistent HTTP connection.

No new protocol. No upgrade handshake. Just HTTP, kept open.


Part Three: The Architecture of a Stream

Let's walk through what actually happens when SSE is set up, because the implementation reveals its elegance.

On the server

The server sets two headers and then… doesn't close the response:

HTTP/1.1 200 OK
Content-Type: text/event-stream
Cache-Control: no-cache
Connection: keep-alive

Then it writes events as they happen:

data: {"at": "2025-05-26T10:00:00Z"}

event: job_complete
data: {"jobId": "abc-123", "status": "done"}

In Node.js / NestJS, this looks like:

@Sse('stream')
liveStream(@Req() req): Observable<MessageEvent> {
  const userId = req.user.sub;
  return this.streamService.subscribe(userId);
}

The @Sse() decorator handles the headers. The Observable emits events. NestJS serializes each emission as an SSE event. Clean.

On the client

The browser has a built-in class for this:

const es = new EventSource(`/v1/stream?token=${jwt}`);

es.addEventListener('connected', (e) => {
  const state = JSON.parse(e.data);
  if (state?.pending) showPendingUI(state);
});

es.addEventListener('job_complete', (e) => {
  renderResult(JSON.parse(e.data));
});

es.addEventListener('job_failed', (e) => {
  showErrorBanner(JSON.parse(e.data));
});

Notice what's not there: no reconnection logic, no heartbeat handling, no binary framing, no protocol negotiation. The EventSource API reconnects automatically when the connection drops, using an exponential backoff. The browser handles it.

This is SSE's superpower: it makes the server the active party while keeping the client implementation trivially simple.

The authentication wrinkle

There is one catch. EventSource cannot set custom headers. You cannot pass Authorization: Bearer <token>.

This is why SSE implementations typically use ?token=<JWT> as a query parameter. The server validates it via a custom auth guard that reads from req.query.token rather than the Authorization header.

It's a small concession. The token is in the URL, which means it shows up in server logs and browser history. For most internal or authenticated use cases, this is acceptable (especially over HTTPS). Just be aware of it.


Part Four: The Problem SSE Solves That WebSockets Don't

Now let's return to that missed event — because this is where SSE stops being theoretical and becomes genuinely important.

Most real-time systems have three delivery channels:

Channel When it works When it fails
WebSocket / Socket.IO Client is connected and socket is open Client just logged in, tab was sleeping, socket dropped
Push notification Client is offline or background Client is online — notification may be suppressed or ignored
SSE stream Always — fires on connect, regardless of socket state Almost never

The socket event is fire-and-forget. If the socket isn't open the instant an event fires, the message is gone. There's no replay, no queue, no retry logic — it simply vanishes.

This is not a bug. It's how event-based systems work. They tell you what's happening right now, not what happened before you were listening.

SSE fixes this at the connection layer with one elegant move: on every connect (or reconnect), the server immediately pushes the current state.

// StreamService — on subscribe
const pendingWork = await this.workRepo.findOne({
  where: { assignedTo: userId, status: In(['pending', 'active']) }
});

// Push immediately — the client gets current state the moment they connect
subject.next({ event: 'connected', data: pendingWork ?? {} });

So when a user logs in a minute after something was queued for them:

  1. Browser opens the SSE connection
  2. Server queries the database: is there anything pending for this user?
  3. Yes — there is
  4. Server immediately emits connected with that state
  5. Browser gets the event within milliseconds of connecting
  6. The UI updates

The event that would have been missed is now received. Not because of polling. Not because of retries. Because the SSE stream delivers current state on connect, the way it always does.


Part Five: SSE vs WebSockets — The Real Comparison

This is where most articles get it wrong. They frame SSE and WebSockets as competitors. They are not. They are complements with different jobs.

SSE is better when:

You only need server → client communication. If the server is the one talking and the client is listening, SSE is simpler, lighter, and requires less infrastructure.

You want automatic reconnection for free. EventSource reconnects automatically. WebSocket clients need to implement retry logic themselves (or use a library like Socket.IO that does it for them).

You're working over HTTP/2. HTTP/2 can multiplex dozens of SSE connections over a single TCP connection, with no additional overhead. WebSockets, being a separate protocol, don't benefit from HTTP/2 multiplexing.

You want to work through load balancers and proxies without special configuration. SSE is just HTTP. Anything that speaks HTTP handles it correctly. WebSockets require the infrastructure to support protocol upgrades — some proxies silently break them.

Your state can be recovered on reconnect. The last-event-id header lets the server resume from where it left off. The client stores the last event ID, sends it on reconnect, and the server replays missed events.

WebSockets are better when:

You need bidirectional communication. Chat, collaborative editing, multiplayer games — anywhere the client needs to send messages as freely as the server. SSE is read-only from the client's perspective.

You need low-latency in both directions simultaneously. A single WebSocket frame is smaller than an HTTP request.

You're building something that genuinely feels like a real-time pipe. Audio/video transport, live cursors, high-frequency trading — these need WebSockets (or WebRTC). You wouldn't replace that with SSE.

In well-designed systems, both coexist:

  • SSE handles discovery and state recovery: "what was I supposed to know about when I connected?"
  • Socket.IO / WebSocket handles ongoing, bidirectional real-time events

Neither replaces the other. They collaborate.


Part Six: The Pros and Cons, Plainly Stated

The case for SSE

Simplicity. The browser's native EventSource API is three lines of code. No library. No WebSocket handshake. No binary framing to worry about.

HTTP native. SSE works with every HTTP-aware piece of infrastructure without configuration: load balancers, CDNs, reverse proxies, logging systems. HTTP/2 support is a bonus.

Automatic reconnection. The browser handles reconnects, backoff, and resumption via Last-Event-ID. You get resilience for free.

Stateless server scaling. Each SSE connection is a long-lived HTTP connection — but if you're using Redis pub/sub or a message broker to fan out events, you can scale horizontally without sticky sessions.

Lower overhead for read-heavy streams. If the server is sending many small events and the client never replies, SSE is more efficient than a WebSocket that supports bidirectional traffic you're not using.

The case against SSE

One-way only. The client cannot send data over the same connection. It must make separate HTTP requests for any client → server communication. For many use cases this is fine; for others it's a non-starter.

No binary support. SSE events are plain text. Binary data must be base64-encoded, which adds overhead. WebSockets can send raw binary frames.

Connection limits. Browsers enforce a limit of 6 HTTP/1.1 connections per domain. If a user has multiple tabs open, each tab's SSE connection counts against this limit. HTTP/2 removes this constraint (it multiplexes connections), but not every environment supports HTTP/2.

The authentication workaround. Because EventSource can't set custom headers, tokens must go in the query string. This exposes them in URLs, logs, and browser history. It's manageable but requires awareness.

No built-in backpressure. If the server sends events faster than the client processes them, the browser buffers them. There's no native mechanism to signal the server to slow down.

Infrastructure support varies. Some CDNs and proxies buffer responses before forwarding them — which breaks SSE entirely. You need streaming to reach the client. Always verify your infrastructure supports streaming HTTP responses before betting on SSE.


Part Seven: The Heartbeat — Keeping the Connection Alive

There's one more operational detail worth understanding: the heartbeat.

Proxies, load balancers, and mobile network stacks have idle timeout rules. If no data flows on an HTTP connection for 30–60 seconds, they kill it. SSE connections can go quiet between events — and silence reads as dead to most infrastructure.

The solution is a heartbeat: a comment line sent every 30 seconds to keep the connection alive.

: heartbeat {"at": "2025-05-26T10:00:30Z"}

Lines starting with : are SSE comments — the browser ignores them, but they keep the TCP connection warm and reset any proxy timers watching the line.

// Server pushes a heartbeat every 30 seconds
interval(30_000).pipe(
  map(() => ({ event: 'heartbeat', data: { at: new Date().toISOString() } }))
)

The client doesn't need to do anything with it. It's infrastructure plumbing — silent, invisible, essential.


Epilogue: The Event That Was Caught

Let's return to that user one final time, but this version of Monday morning goes differently.

The action is triggered at 9:02 AM. The backend creates a pending state and pushes an event through the SSE stream to the user's ID. But the user isn't connected yet — their browser is loading.

At 9:03 AM, they finish logging in. The app authenticates and boots two things simultaneously:

this.socket.connect();              // WebSocket — for ongoing real-time events
this.streamService.connect(token);  // SSE — for state discovery on connect

The SSE connection opens. The server queries the database: is there anything pending for this user? Yes. It immediately emits:

event: connected
data: {"id":"abc-123","status":"pending","createdAt":"2025-05-26T09:02:00Z"}

The app receives it. The UI updates. The notification renders.

No polling. No retry. No missed event. Just a persistent HTTP connection that delivered current state the moment it was asked.

That is what SSE does. Quietly. Reliably. Without ceremony.


Appendix: Quick Reference

SSE event format

id: <optional event ID for resumption>
event: <optional event name, default: "message">
data: <event payload, one line per data: prefix>
retry: <optional retry interval in ms>

Browser API

const es = new EventSource('/stream?token=JWT');

es.onopen = () => console.log('connected');
es.onerror = (e) => console.error('error', e);
es.onmessage = (e) => console.log('message', e.data); // catches default "message" events

es.addEventListener('custom-event', (e) => {
  const data = JSON.parse(e.data);
});

es.close(); // to disconnect

Required server headers

Content-Type: text/event-stream
Cache-Control: no-cache
Connection: keep-alive
X-Accel-Buffering: no  // ← critical for nginx — disables response buffering

SSE vs WebSocket at a glance

SSE WebSocket
Direction Server → Client only Bidirectional
Protocol HTTP Upgraded HTTP (ws://)
Browser API EventSource (built-in) WebSocket (built-in)
Auto-reconnect ✅ Native ❌ Manual
Binary support ❌ Text only ✅ Native
HTTP/2 mux ✅ Yes ❌ No
Header auth ❌ Query param only ✅ Yes
Proxy/LB friendly ✅ Yes ⚠️ Requires support