Vivian Voss

Server-Sent Events

javascript rust web architecture

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.

Reconnect After Failure WebSocket 1. Detect disconnection (onclose) 2. Implement backoff timer 3. Reconnect manually 4. Re-authenticate 5. Reconcile missed state Your responsibility. Your code. Your bugs. EventSource (SSE) Automatic. The specification demands it. Every browser implements it. Your responsibility: none.

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.

The Protocol: Plain HTTP Server Rust, Go, Node, Python text/event-stream Browser new EventSource(url) What the server sends: data: {"price": 142.50} data: {"price": 143.10} data: {"price": 142.95} plain text line by line human-readable No frame encoding. No masking. No opcodes. No binary protocol. Every proxy, CDN, and load balancer understands it. Because it is HTTP.

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

The Decision Does the client need to send data to the server? No → SSE Dashboards Notifications, feeds, logs Progress bars, stock tickers Auto-reconnect. Plain HTTP. 2 lines Yes → WebSocket Chat Multiplayer games Collaborative editing Full duplex. Custom reconnect. Upgrade If the client never sends, SSE is simpler, lighter, and recovers without your help.

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.