Real-time: Server-Sent Events
When SSE beats WebSockets. EventSource, retry semantics, and the simpler half of real-time.
You read the WebSockets lesson and thought, "Wait, this is a lot of code for something that just receives updates from a server." You're right. Half the time you don't need a phone call — you need the server to push updates to the client, and that's it. No client-to-server messages, no binary frames, no handshake protocol to debug. Server-Sent Events is that simpler half: a one-way HTTP stream the server holds open, sending text messages whenever there's news.
If WebSockets are a phone call, SSE is a radio. The station broadcasts; you tune in. Listeners can't talk back over the radio — but for a lot of cases, you don't need to.
What an SSE stream looks like
An SSE response is a normal HTTP response with Content-Type: text/event-stream. The body never finishes until the connection closes. It's plain UTF-8 text with a tiny format: each message is one or more key: value lines, terminated by a blank line.
data: hello
data: another message
event: comment
data: {"id":42,"text":"nice post"}
That's the whole protocol. Three keys you'll meet:
data:— the message payload (a string; you parse JSON yourself).event:— an optional event name (default:"message").id:— an optional ID the browser remembers, so it can resume after a reconnect.
The browser parses these for you; on the JS side, you just receive events.
EventSource on the client
new EventSource(url) opens the connection and starts listening. The default message event fires for every entry without a custom event: key.
const stream = new EventSource("/api/comments/stream");
stream.addEventListener("message", (event) => {
const comment = JSON.parse(event.data);
addComment(comment);
});
stream.addEventListener("error", () => {
/* the browser is already retrying; you can update UI */
setStatus("reconnecting");
});There's no open event you need to wait for — the moment the connection succeeds, messages start flowing. There's no send method either; this is server-to-client only.
event.data is whatever string the server put after data:. If it's JSON, you parse. If it's plain text, you use it directly. Multi-line data: blocks get joined with newlines automatically.
EventSource always uses GET. There's no way to send a POST body when opening the stream — if you need to pass parameters, put them in the URL or set them as cookies/headers via the request that authenticated the user.
Named events
When the server uses event: someName, you listen for that name instead of message.
stream.addEventListener("comment", (event) => {
addComment(JSON.parse(event.data));
});
stream.addEventListener("typing", (event) => {
showTyping(JSON.parse(event.data));
});
stream.addEventListener("presence", (event) => {
updatePresence(JSON.parse(event.data));
});This is the SSE equivalent of the type field convention you saw in WebSockets — but the protocol gives it to you for free, and event listeners route automatically. Each handler gets only the events it asked for.
event: presence\\ndata: ...\\n\\n. Your stream.addEventListener("message", ...) handler never fires. Why?Built-in reconnect
Here's the killer feature of SSE: reconnection is automatic. If the connection drops, the browser waits a few seconds and retries. If the server sent any id: lines, the browser remembers the last one and includes it in the reconnect request as a Last-Event-ID header — so the server can resume from where it left off.
You write zero code for any of this. The exponential-backoff loop you wrote for WebSockets is built into EventSource.
/* Server-side: */
id: 1042
data: {"text":"...","id":1042}
/* On reconnect, the browser sends: */
GET /api/stream
Last-Event-ID: 1042The server reads Last-Event-ID and resumes. Combined with named events, this gives you a real-time feed that survives flaky networks with no client code at all.
To stop listening: stream.close(). After that, no more reconnect attempts.
SSE vs WebSockets
The decision tree is short.
- One-way (server → client) updates? SSE. Notifications, live counters, log tails, an LLM streaming a reply, comments appearing in real time. SSE is shorter to write, gets reconnect for free, and runs over plain HTTP — proxies, load balancers, and CDNs handle it without special configuration.
- Two-way messaging? WebSockets. Chat, multiplayer games, collaborative editing, anything where the client also sends frequent messages. SSE can't do this — for client-to-server you'd need a separate
fetch, which gets clunky fast. - Binary data, low-latency frames? WebSockets. SSE is text-only.
A common pattern: SSE for the firehose of server pushes, plus regular fetch for the occasional client action. You get the simplicity of SSE without losing the ability to write to the server when needed.
Try it yourself
id: 88 with each event. The connection drops; the browser reconnects. What does the new request include?