Server‑Sent Events (SSE): the simplest real‑time you’re not using

If you need live updates in the browser without the complexity of full‑duplex protocols, Server‑Sent Events (SSE) are a beautifully simple fit. As a Python-leaning engineer, I love SSE because it rides on plain HTTP, integrates cleanly with existing backends, and is ridiculously easy to consume from JavaScript and TypeScript.

What SSE is (and how it works)


Server‑Sent Events (SSE): the simplest real‑time you’re not using

  • SSE is an HTTP response that never really “finishes.” The server sets Content-Type: text/event-stream and streams events as lines of text.
  • The browser uses EventSource to open a long‑lived GET request and dispatches messages as they arrive.
  • The framing is line-based and simple:
    • data: your payload (can appear multiple times per event; lines are joined with newlines)
    • event: optional custom event name (otherwise the event is message)
    • id: optional; if present, browsers send Last-Event-ID on reconnect
    • retry: optional; client reconnection delay hint in milliseconds
  • SSE is unidirectional: server → client only. For many products, that’s all you need.

When to reach for SSE

  • Live dashboards and analytics
  • Notifications and activity feeds
  • Log/trace tailing in web consoles
  • Progress updates for long-running jobs
  • Collaborative UI where the server pushes state changes

Quick look at the wire format
id: 42
event: notification
data: {“title”:”Build finished”,”status”:”success”}

id: 43
data: first line of payload
data: second line of payload

Note: A blank line terminates an event. Multiple data lines become a single string with embedded newlines.

Client: JavaScript quick start
const es = new EventSource(‘/events’);

es.onopen = () => {
console.log(‘SSE connected’);
};

es.onmessage = (event) => {
// Default “message” events
const payload = JSON.parse(event.data);
console.log(‘message:’, payload);
};

es.addEventListener(‘notification’, (event) => {
const payload = JSON.parse(event.data);
console.log(‘notification:’, payload);
});

es.onerror = (err) => {
// EventSource will auto-reconnect unless you call close()
console.warn(‘SSE error (auto-reconnecting):’, err);
};

// Close when you no longer need updates (important on SPA route changes)
// es.close();

Notes

  • EventSource only supports GET and does not let you set arbitrary headers. If you need auth, prefer cookies/sessions or pass a short-lived token in the query string. For cross-origin, configure CORS normally.
  • You can use withCredentials: true if you rely on cookies across origins (ensure Access-Control-Allow-Credentials: true on the server and do not use wildcard origins).

Client: TypeScript utility with typed handlers
Type ServerEventHandler = (data: T) => void;

interface ConnectSSEOptions<TDefault = unknown> {
token?: string; // if you need a bearer-like token in the query
withCredentials?: boolean; // use cookies
onOpen?: () => void;
onError?: (e: Event) => void;
onMessage?: ServerEventHandler; // default “message” events
events?: Record<string, ServerEventHandler>; // custom event handlers
}

function connectSSE<TDefault = unknown>(
url: string,
opts: ConnectSSEOptions = {}
): EventSource {
const qs = opts.token ? (url.includes(‘?’) ? ‘&’ : ‘?’) + ‘token=’ + encodeURIComponent(opts.token) : ”;
const es = new EventSource(url + qs, { withCredentials: !!opts.withCredentials });

es.onopen = () => opts.onOpen?.();
es.onerror = (e) => opts.onError?.(e);

if (opts.onMessage) {
es.onmessage = (ev) => {
try { opts.onMessage!(JSON.parse(ev.data)); }
catch { opts.onMessage!(ev.data as unknown as TDefault); }
};
}

if (opts.events) {
Object.entries(opts.events).forEach(([name, handler]) => {
es.addEventListener(name, (ev) => {
const me = ev as MessageEvent;
try { handler(JSON.parse(me.data)); }
catch { handler(me.data); }
});
});
}

return es;
}

// Example usage
const es = connectSSE(‘/events’, {
onOpen: () => console.log(‘connected’),
onMessage: (d) => console.log(‘default message’, d),
events: {
notification: (d) => console.log(‘notification’, d)
}
});

// Later: es.close();

Server: Python (FastAPI) minimal SSE endpoint
from fastapi import FastAPI, Request
from fastapi.responses import StreamingResponse
import asyncio, json, time

app = FastAPI()

async def sse_event_generator(request: Request):
counter = 0
while True:
if await request.is_disconnected():
break

# Heartbeat every 15s if you have no real data to send
# yield ": keep-alive\n\n"

counter += 1
payload = {"counter": counter, "ts": time.time()}

# Example custom event
yield f"id: {counter}\n"
yield "event: tick\n"
yield f"data: {json.dumps(payload)}\n\n"

await asyncio.sleep(1)

@app.get(‘/events’)
async def events(request: Request):
headers = {
‘Cache-Control’: ‘no-cache’,
‘Connection’: ‘keep-alive’,
‘X-Accel-Buffering’: ‘no’ # disables buffering in some NGINX setups
}
return StreamingResponse(sse_event_generator(request), media_type=’text/event-stream’, headers=headers)

Production notes for servers

  • Heartbeats: Send a comment line (“: keep-alive\n\n”) every 15–30 seconds to keep proxies/load balancers from idling out the connection.
  • Buffering: On NGINX, set proxy_buffering off for SSE locations; send X-Accel-Buffering: no. On HAProxy/ALB, disable response buffering and extend timeouts.
  • Timeouts: Increase idle timeouts. Many platforms default to 30–60s; keep the connection chatty via heartbeats.
  • HTTP/2: SSE works great over HTTP/2. Each client still consumes a stream, but HTTP/2 helps with head-of-line blocking and multiplexing other requests.
  • Reconnect and replay: If you emit id: values, browsers send Last-Event-ID on reconnect. Your server can use this to replay missed messages (often via a short-lived backlog or a pub/sub buffer).
  • Auth: EventSource can’t set custom headers. Prefer cookie-based auth or put a short-lived token in the query string and validate it server-side. Use HTTPS.
  • CORS: If cross-origin, return the correct Access-Control-Allow-Origin (and possibly Allow-Credentials) headers.
  • Scaling: Fan-out using a message broker (Redis Pub/Sub, NATS, etc.). For multiple app instances, either sticky sessions or shared pub/sub so any node can serve the stream.

SSE vs. other real-time techniques

  • SSE vs WebSockets

    • Directionality: SSE is server→client only; WebSockets are full duplex. If clients must send frequent real-time messages back, WebSockets fit better.
    • Simplicity: SSE is just HTTP; no custom protocols. Auto-reconnect and Last-Event-ID are built in.
    • Binary: SSE is text-only. WebSockets handle binary efficiently.
    • Infra compatibility: SSE traverses proxies/CDNs more easily because it’s plain HTTP; WebSockets still work broadly but may need extra config in legacy networks.
    • Headers: In browsers, both EventSource and WebSocket lack arbitrary header control. SSE leans on cookies or query params; WebSocket has subprotocols but not custom headers in the standard API.
    • Message ordering and reliability: Both preserve order per connection. SSE’s Last-Event-ID gives you a simple replay hook; with WebSockets, implement your own resume logic.
  • SSE vs Polling

    • Efficiency: Polling wakes the server and client repeatedly even when there are no updates. SSE pushes only when there’s data.
    • Latency: SSE delivers near-instant updates; polling trades latency for fewer requests, or vice versa.
    • Complexity: SSE is usually simpler than building a disciplined polling strategy with ETags, backoff, and jitter.
  • SSE vs Long-polling

    • Long-polling holds a request open until data arrives, then completes, and the client immediately opens a new one. SSE keeps a single stream open and is more efficient for sustained updates.
    • Both work when WebSockets are blocked, but SSE is cleaner to implement and reason about.
  • SSE vs fetch streaming / ReadableStream

    • With fetch you can stream bytes and parse your own framing. It’s flexible (you can use POST and headers) but you reimplement reconnect, heartbeats, and framing. SSE gives you a standard event format and built-in reconnect.
  • SSE vs gRPC server streaming

    • gRPC uses HTTP/2 and a binary protocol. In browsers you typically need gRPC-Web and a proxy. SSE is much easier to adopt for simple UI updates.

Trade-offs and limitations

  • Unidirectional: No client→server channel. Pair with normal HTTP POST/fetch for client input, or choose WebSockets if you need chatty bidirectional flows.
  • Text only: Wrap binary data (e.g., Base64) if necessary, or use WebSockets for binary.
  • Browser limits: Browsers limit concurrent connections per origin. Share one SSE connection across tabs or routes when possible.
  • Headers and POST: EventSource can’t set custom headers or use POST. If you need those, consider fetch streaming or WebSockets.
  • Memory/FD footprint: Many thousands of concurrent streams require an async, event-driven server and tuned OS limits.

Testing and debugging tips

  • curl: curl -N http://localhost:8000/events (the -N disables buffering so you see events as they stream).
  • DevTools: Watch Network → EventStream; you’ll see events as they arrive.
  • Chaos: Simulate server restarts and network drops; verify Last-Event-ID logic and idempotency.

A simple mental model for choosing

  • Use SSE when the server needs to push text updates to the browser and clients only occasionally send data back via normal HTTP.
  • Use WebSockets when you need real-time bidirectional messaging or binary payloads.
  • Use polling or long-polling only for very simple or legacy scenarios where persistent connections are not an option.

SSE is the underrated workhorse of real-time web UIs. It lets you ship features quickly, scales well with the right async server and proxy settings, and keeps your codebase approachable. Before you reach for a WebSocket hammer, ask if a one-way stream will do the job—SSE might be exactly what you need.

– Pythia

Comments

Leave a Reply

Your email address will not be published. Required fields are marked *