Stack Patterns ■ Episode 9
Last episode built a WebSocket. Full duplex. Both sides talk. Splendid for chat. Rather generous for a notification badge.
Most real-time features are one-way: the server knows something changed, the client needs to hear about it. Dashboards, feeds, build logs, stock tickers, deployment progress bars. The client never sends. It listens.
The browser solved this in 2015 with one line of JavaScript that nobody teaches. Browser support: 97 per cent. Older than most npm packages in your dependency tree. Rather embarrassing, that.
The Client
const source = new EventSource('/events');
source.onmessage = e =>
document.querySelector('#feed').textContent = e.data;
One line to connect. One line to handle. No npm. No cleanup. No
reconnect logic. EventSource reconnects automatically.
The browser handles it. You do not.
WebSocket requires an onclose handler, a backoff timer,
and state reconciliation after reconnect. EventSource requires nothing.
It simply reconnects and resumes. The
specification
demands it. Not a library convention. Not a best practice. A specification
requirement, implemented by every browser, tested by every browser vendor,
maintained without your involvement.
The Server
Rust, using axum:
async fn events() -> Sse<impl Stream<Item = Result<Event, Infallible>>> {
let stream = stream::repeat_with(|| {
Event::default().data("update")
})
.map(Ok)
.throttle(Duration::from_secs(1));
Sse::new(stream).keep_alive(KeepAlive::default())
}
One function. Returns a stream. The runtime handles backpressure, keep-alive, and client disconnection. No connection tracking. No upgrade handshake. No WebSocket frame encoding. Plain HTTP from start to finish.
The complete server is 15 lines. The complete client is 4 lines. Nineteen lines of code for real-time server push. One suspects the Socket.IO authors would prefer you did not know this.
The Protocol
Three headers. That is the entire specification:
Content-Type: text/event-stream
Cache-Control: no-cache
Connection: keep-alive
The server sends lines prefixed with data:. The browser
parses them. No frame encoding. No masking. No opcodes. No binary
protocol. HTTP from start to finish. Every proxy understands it.
Every CDN can pass it through. Every load balancer works without
configuration. Every debugging tool can read it.
Compare this to WebSocket. WebSocket upgrades the HTTP connection to a different protocol. The upgrade requires a handshake. The frames require masking. The proxy may not understand the upgraded connection. The CDN may buffer it. The load balancer may need configuration. All because you needed a notification badge.
Named Events and Last-Event-ID
The specification goes further than most developers realise. The server can send named events:
event: price-update
data: {"symbol": "AAPL", "price": 142.50}
event: trade-alert
data: {"symbol": "AAPL", "action": "sell"}
The client subscribes selectively:
source.addEventListener('price-update', e => {
updateTicker(JSON.parse(e.data));
});
source.addEventListener('trade-alert', e => {
showAlert(JSON.parse(e.data));
});
And when the connection drops, the browser sends a Last-Event-ID
header on reconnect. The server can use this to resume from where the
client left off. No missed events. No state reconciliation. The
specification thought of it before you did.
When to Use What
One-way server push: SSE. Dashboards, notifications, logs, feeds, progress bars, deployment status, build output.
Bidirectional: WebSocket. Chat, multiplayer, collaborative editing.
The question is not which is better. The question is whether both sides need to talk. If the client never sends, SSE is simpler, lighter, and recovers from failure without your help. If you reach for WebSocket because your dashboard needs live data, you have built a telephone to listen to the radio.
The Complete Example
The server, in its entirety:
use axum::{response::sse::{Event, Sse, KeepAlive}, routing::get, Router};
use futures_util::stream::{self, Stream};
use std::{convert::Infallible, time::Duration};
use tokio_stream::StreamExt;
#[tokio::main]
async fn main() {
let app = Router::new().route("/events", get(events));
let l = tokio::net::TcpListener::bind("0.0.0.0:3000")
.await.unwrap();
axum::serve(l, app).await.unwrap();
}
async fn events() -> Sse<impl Stream<Item = Result<Event, Infallible>>> {
let stream = stream::repeat_with(|| Event::default().data("update"))
.map(Ok).throttle(Duration::from_secs(1));
Sse::new(stream).keep_alive(KeepAlive::default())
}
The client, in its entirety:
<p id="feed">waiting...</p>
<script>
const source = new EventSource('/events');
source.onmessage = e => feed.textContent = e.data;
</script>
Fifteen lines of Rust. Four lines of HTML. Real-time server push. Automatic reconnection. No npm packages. No framework. No build step. One rather appreciates infrastructure that does not require a babysitter.
Most real-time features are one-way. The browser solved this in 2015 with one line of JavaScript. EventSource reconnects automatically. The specification demands it. Three headers. No frame encoding. No upgrade handshake. Plain HTTP. If the client never sends, you do not need a telephone. You need a radio.