I reached for WebSockets the first three times I needed to stream data from a server to the browser. Every time I regretted it. WebSockets are bidirectional, which is great when you need bidirectional, and a pain when you don’t. Most of the time I didn’t.
What I actually needed was “the server pushes updates to the browser, the browser just listens.” That’s Server-Sent Events. A plain HTTP connection that stays open, the server writes lines of text, the browser reads them. No handshake protocol, no reconnection dance, no extra library. The browser has a built-in API for it.
This is the setup I use now when something streams in one direction. Node on the server, React on the client. It’s less code than you’d think.
When SSE is the right tool
Before wiring anything up, make sure you actually want SSE and not WebSockets or polling.
SSE is great for:
- Live notifications or activity feeds
- Progress updates from a long-running job
- AI streaming responses (this is huge right now)
- Real-time metrics dashboards
- Anything where the server pushes and the client just reads
Use WebSockets when:
- The client needs to send messages back on the same connection
- You’re doing something truly bidirectional like chat or collaborative editing
- You need binary data
Use polling when:
- Updates are rare and slight staleness is fine
- You want the simplest thing that could possibly work
For everything in the middle, SSE is usually the right answer. Most people reach for WebSockets out of habit.
The server side in Node
The core of an SSE endpoint is: set the right headers, write events, don’t end the response until the client disconnects.
Here’s a minimal Express handler:
import express from "express"
const app = express()
app.get("/events", (req, res) => {
res.setHeader("Content-Type", "text/event-stream")
res.setHeader("Cache-Control", "no-cache, no-transform")
res.setHeader("Connection", "keep-alive")
res.flushHeaders()
const send = (data: unknown) => {
res.write(`data: ${JSON.stringify(data)}\n\n`)
}
const interval = setInterval(() => {
send({ time: new Date().toISOString() })
}, 1000)
req.on("close", () => {
clearInterval(interval)
})
})
app.listen(3001)
Three things to notice.
The format is strict. Each event is data: <payload>\n\n. The double newline is the delimiter. Miss it and nothing shows up on the client.
flushHeaders() is important. Without it, the response might buffer and the client sees nothing until the server flushes. In production behind a proxy like Nginx or a CDN, this matters more.
Cleanup on disconnect. The req.on("close") handler is non-negotiable. If you don’t clear timers or stop listeners when the client disconnects, you’ll leak resources for every abandoned connection. I learned this by running out of memory on a demo.
Sending typed events
Raw SSE only has “data.” But the spec lets you tag events with a name so the client can listen for specific types.
res.write(`event: notification\n`)
res.write(`data: ${JSON.stringify({ message: "Hello" })}\n\n`)
res.write(`event: status\n`)
res.write(`data: ${JSON.stringify({ state: "connected" })}\n\n`)
On the client, you then listen for notification or status separately. If you don’t send event:, the default event name is message.
I use this for any SSE endpoint that sends more than one kind of update. It’s cleaner than sticking a type field in the payload and switching on it.
Sending an ID for reconnect
If the client drops the connection (network hiccup, tab backgrounded, whatever), the browser will reconnect automatically. When it reconnects, it sends back the ID of the last event it received in a header called Last-Event-ID.
You can use this to resume where you left off:
app.get("/events", (req, res) => {
// set headers, flushHeaders, etc.
const lastId = req.headers["last-event-id"]
let eventId = lastId ? parseInt(lastId as string) : 0
const sendEvent = (data: unknown) => {
eventId++
res.write(`id: ${eventId}\n`)
res.write(`data: ${JSON.stringify(data)}\n\n`)
}
// ...
})
Whether you actually resume depends on what you’re streaming. For a live clock, who cares — the client just gets the latest value. For a progress stream where you don’t want to miss updates, you’d cross-check lastId against a log of events you’ve sent and replay anything missed.
Most of the time the basic version is enough. But the ID mechanism is there when you need it.
The React side
React on the client is almost boring. The browser has EventSource, and we just wrap it in a hook.
import { useEffect, useState } from "react"
type TimeEvent = { time: string }
export function Clock() {
const [time, setTime] = useState<string | null>(null)
useEffect(() => {
const source = new EventSource("http://localhost:3001/events")
source.onmessage = (event) => {
const parsed: TimeEvent = JSON.parse(event.data)
setTime(parsed.time)
}
source.onerror = () => {
// the browser will auto-reconnect, so we usually don't need to do anything
// log it if you're debugging
}
return () => source.close()
}, [])
return <div>{time ?? "Waiting..."}</div>
}
That’s the whole thing. The cleanup in the useEffect return is the bit nobody remembers. Without it, React’s strict mode and remounts will leave you with zombie connections piling up.
If you’re listening for named events instead of plain onmessage:
source.addEventListener("notification", (event) => {
const data = JSON.parse((event as MessageEvent).data)
// handle notification
})
The reusable hook
Once I realized I was writing this pattern in every component, I pulled it into a hook:
export function useEventSource<T>(url: string) {
const [data, setData] = useState<T | null>(null)
const [status, setStatus] = useState<"connecting" | "open" | "closed">("connecting")
useEffect(() => {
const source = new EventSource(url)
source.onopen = () => setStatus("open")
source.onerror = () => setStatus("closed")
source.onmessage = (event) => {
setData(JSON.parse(event.data))
}
return () => {
source.close()
setStatus("closed")
}
}, [url])
return { data, status }
}
Call it like:
const { data, status } = useEventSource<TimeEvent>("/events")
Simple. Good enough for 90% of cases. If you need more — accumulating events into a list, or handling multiple event types — write a more specific hook for that specific endpoint.
The production gotchas
A few things bit me when I moved from local to deployed.
Nginx buffers by default. If your SSE endpoint is behind Nginx, you need proxy_buffering off for the location serving events. Otherwise Nginx holds onto your events until the buffer fills, and the client sits there staring at nothing.
Cloudflare’s free tier has a 100-second idle timeout. If your events are rare, send a keep-alive comment every 30 seconds to keep the connection warm:
res.write(`:keep-alive\n\n`)
Lines starting with : are comments in SSE. The client ignores them. But they reset the idle timer on intermediaries.
Max 6 connections per origin per browser. Old HTTP/1.1 limit. Still relevant because SSE uses HTTP/1.1 by default. If you have several SSE connections from different components in the same tab, you’ll max out. The fix is either HTTP/2 (which allows many more) or multiplexing all your events through one connection and filtering on the client.
Authentication. EventSource doesn’t let you set custom headers. So “send a Bearer token” doesn’t work out of the box. Options: cookie-based auth (works fine), a token in the URL query string (fine for short-lived tokens), or use fetch with streaming and parse SSE yourself (more code, more control).
When I still reach for WebSockets
Anything where the client is sending a steady stream back. Real chat, collaborative editing, games, anything where the server needs to respond to rapid client input. SSE alone doesn’t cut it there. You either need WebSockets or you pair SSE with a normal POST endpoint for the client-to-server direction, which is honestly fine for a lot of cases.
Server-Sent Events are the most underused tool in the browser platform. They’re built-in, they’re simple, they auto-reconnect, and they solve the “push updates to the browser” problem with about 20 lines of server code and 10 lines of client code. If you’ve been reaching for WebSockets for everything, try SSE on the next thing. You’ll probably keep it.