When to use WebSockets, Server-Sent Events or long polling for real-time data in a web application. A practical comparison covering latency, connection overhead, firewall behaviour and implementation complexity.

Real-time data on the web means updates appear in the browser within milliseconds of happening on the server, without the user having to refresh the page. Think of a live sports score, a chat message or a sensor reading updating in front of you. Making this work requires the server to push data to the browser proactively, rather than waiting for the browser to ask for it.
Every real-time feature on the web reduces to the same question: how does new data get from the server to the browser without the browser asking for it? There are three standard answers: long polling, Server-Sent Events and WebSockets. They each make different trade-offs and the right choice depends on your specific constraints.
Long polling works over standard HTTP. The browser sends a request, the server holds it open until it has new data, then responds. The browser immediately sends another request. It looks like a real-time connection but it is really a sequence of requests with minimal delay between them.
The advantage is simplicity: it works everywhere HTTP works, requires no special server support and passes through every proxy and firewall without issue. The disadvantage is overhead: each response requires a new request, which means repeated HTTP handshakes and headers. For high-frequency updates (more than a few per second) this overhead becomes significant.
Server-Sent Events (SSE) is a one-directional streaming protocol over HTTP. The server sends a stream of text/event-stream data; the browser reads it via the EventSource API. The connection stays open. New data is pushed to the browser without a new request.
SSE is the right choice when data flows server-to-client only: live logs, notification feeds, stock prices, sensor telemetry dashboards. The protocol is simple, reconnects automatically on disconnect and works through most proxies. The limitation is directionality: if you need the client to also send data over the same connection, SSE is the wrong tool.
// Next.js App Router route handler for SSE
export async function GET() {
const stream = new ReadableStream({
start(controller) {
const send = (data: object) => {
controller.enqueue(`data: ${JSON.stringify(data)}\n\n`)
}
const interval = setInterval(() => {
send({ time: Date.now(), value: Math.random() })
}, 1000)
return () => clearInterval(interval)
},
})
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
},
})
}WebSockets provide a full-duplex persistent connection between browser and server. Either side can send data at any time. The initial HTTP handshake upgrades the connection to the WebSocket protocol; after that, frames are sent with minimal overhead. This makes WebSockets the right choice for chat applications, collaborative editing, multiplayer games and any feature where the browser also needs to send frequent messages.
The trade-off is complexity. WebSocket connections must be managed on the server side: connection pools, heartbeats, reconnection logic, horizontal scaling across multiple servers. In a serverless environment like Vercel, persistent WebSocket connections require a separate service such as Ably or Pusher because serverless functions terminate after each response.
The live status cards on the homepage (Spotify, PS5, Discord) use periodic polling rather than WebSockets or SSE. The status changes slowly enough (every few seconds) that the overhead of a persistent connection is not justified. Each card fetches from a Next.js API route that reads from Upstash Redis, which is kept fresh by a Cloudflare Worker polling the relevant APIs. The result is eventual consistency with a lag of a few seconds, which is acceptable for presence data.
For truly real-time sensor dashboards like the Phaemos frontend, SSE makes more sense. The sensor node posts telemetry to the FastAPI backend every 5 seconds; the Next.js dashboard could subscribe to an SSE stream from the backend rather than polling an API route every 5 seconds. The trade-off is that SSE requires a persistent server connection, which is straightforward on a dedicated server but requires additional routing on serverless infrastructure.
One underused feature of SSE is built-in reconnection. If the connection drops, the browser automatically reconnects after a configurable retry interval. If the server sends an `id:` field with each event, the browser sends a `Last-Event-ID` header on reconnect. A well-implemented SSE server can use this to resume from where the client left off - useful for log streaming where you do not want to re-send the entire history.
// SSE with event IDs and retry interval - client automatically reconnects
export async function GET() {
let eventId = 0
const stream = new ReadableStream({
start(controller) {
const send = (data: object) => {
controller.enqueue(`id: ${eventId++}\nretry: 3000\ndata: ${JSON.stringify(data)}\n\n`)
}
const interval = setInterval(() => send({ time: Date.now() }), 1000)
return () => clearInterval(interval)
},
})
return new Response(stream, {
headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-cache" },
})
}
// Client-side - browser handles reconnection automatically
const source = new EventSource("/api/stream")
source.onmessage = (e) => console.log(JSON.parse(e.data))
source.onerror = () => console.log("reconnecting...")WebSocket connections can silently die. A load balancer or NAT gateway may drop an idle connection without sending a close frame. The browser and server both believe the connection is alive; neither side gets an error until they try to send something. The standard mitigation is a ping-pong heartbeat: the server sends a ping frame every 30 seconds, and the client responds with a pong. If three consecutive pings go unanswered, the server closes the connection and the client reconnects.
Browser WebSocket does not expose the ping/pong API directly - you implement application-level heartbeats with a JSON message. For a managed WebSocket service like Ably or Pusher, heartbeats are handled for you. For a self-managed server using the `ws` library in Node.js, `ws` handles the protocol-level ping/pong automatically if you call `ws.ping()` on the server side.
JavaScript's Event Loop Without the Metaphors
Most explanations of the JavaScript event loop use hand-wavy analogies. This one explains the actual mechanism: the call stack, the task queue, the microtask queue and why the order matters for real code.
Phaemos: Building a Predictive Maintenance Platform from Firmware to Dashboard
How I am building Phaemos - a full-stack predictive maintenance platform with four hardware nodes (ESP32, STM32 Black Pill, Arduino Nano, Raspberry Pi Pico 2W), 11 sensors, a FastAPI backend, Isolation Forest ML and a live Next.js dashboard.
Building My Portfolio: Decisions, Stack and What I Learned
How I rebuilt my portfolio from scratch and kept building it: Next.js App Router, TypeScript, Tailwind CSS, Upstash Redis, Vercel, Python daemons for live device status, a custom PS5 OAuth v2 Cloudflare Worker, 5-tier GPC game detection with IGDB cover art, Discord presence via Lanyard and Spotify now-playing - and what shipping something personal actually teaches you.
Comments
Have a thought, correction or question? Sign in with GitHub - I read every comment and reply where I can.