Real-time doesn't always mean WebSockets. Picking the right tool changes everything.
In this article we'll cover when polling is actually the right answer, why SSE is the most underrated real-time technology on the web, when WebSockets are genuinely worth the complexity, how to handle reconnections properly, lessons from building real-time features at scale and a clear decision framework so you stop defaulting to WebSockets out of habit.
Every time a developer hears "real-time", the next word out of their mouth is usually "WebSockets."
I get it. WebSockets feel like the serious, production-grade choice. Polling feels naive. SSE feels like something you read about once and forgot. So the default becomes WebSockets and teams end up maintaining infrastructure complexity they didn't need for a problem that SSE would have solved in 30 lines.
I've built real-time features on platforms handling more than 10 million active users. Subscription verification flows, live event score updates, notification systems. Different problems, different tools. And the most important lesson I've taken from all of it is this: pick the simplest technology that actually solves the problem.
This article will help you do that.
Polling - Not Always the Wrong Answer
Polling gets dismissed quickly. "Just use WebSockets" is the usual response. But polling has a place and understanding when it's appropriate saves you from over-engineering.
Short Polling
The simplest form. Make a request every N seconds, get a response, repeat.
function startPolling(interval = 5000) {
return setInterval(async () => {
const data = await fetch('/api/status').then(res => res.json())
updateUI(data)
}, interval)
}
This works fine when:
- The data changes infrequently and slight delays are acceptable.
- You have a small number of concurrent users.
- The implementation complexity budget is low and you need something working fast.
Where it falls apart is scale. If 100,000 users are polling every 5 seconds, that's 20,000 requests per second hitting your server for data that probably hasn't changed. At that point polling isn't simple anymore - it's expensive.
Smarter Polling
If you're going to poll, at least do it intelligently. Back off when the tab is hidden, increase the interval when responses show no change, stop entirely when the user is inactive.
function smartPoll(fetchFn, options = {}) {
const { baseInterval = 5000, maxInterval = 60000 } = options
let currentInterval = baseInterval
let timeoutId = null
async function poll() {
if (document.hidden) {
timeoutId = setTimeout(poll, maxInterval)
return
}
const data = await fetchFn()
currentInterval = data.changed ? baseInterval : Math.min(currentInterval * 1.5, maxInterval)
timeoutId = setTimeout(poll, currentInterval)
}
poll()
return () => clearTimeout(timeoutId)
}
This alone can cut unnecessary requests significantly without changing the fundamental approach.
When to use polling: low-frequency updates, small user base, dashboard data that refreshes every minute or so, situations where you need something working today and can revisit later.
SSE - The Underrated Middle Ground
Server-Sent Events is the technology most developers skip over on their way to WebSockets. That's a mistake.
SSE is a one-directional, HTTP-native protocol. The server pushes data to the client over a persistent connection. The client listens. That's it.
What makes SSE genuinely compelling:
- It runs over regular HTTP. No protocol upgrade, no special infrastructure, no load balancer configuration changes.
- The browser handles reconnection automatically. If the connection drops, the browser retries with the last event ID so you don't miss events.
- It works through HTTP/2 multiplexing, meaning multiple SSE connections share a single TCP connection.
- It's dramatically simpler to implement than WebSockets on both the client and server side.
function connectToEventStream(url, handlers) {
const eventSource = new EventSource(url, { withCredentials: true })
eventSource.onopen = () => {
console.log('SSE connection established')
}
eventSource.onmessage = (event) => {
const data = JSON.parse(event.data)
handlers.onMessage(data)
}
eventSource.onerror = (err) => {
if (eventSource.readyState === EventSource.CLOSED) {
handlers.onClose()
}
}
// Listen to named events
eventSource.addEventListener('subscription-update', (event) => {
handlers.onSubscriptionUpdate(JSON.parse(event.data))
})
return () => eventSource.close()
}
The server side is equally straightforward. Any server that can keep an HTTP connection alive and stream data to it can serve SSE. No WebSocket library needed.
// Express example
app.get('/events', (req, res) => {
res.setHeader('Content-Type', 'text/event-stream')
res.setHeader('Cache-Control', 'no-cache')
res.setHeader('Connection', 'keep-alive')
const sendEvent = (eventName, data) => {
res.write(`event: ${eventName}\n`)
res.write(`data: ${JSON.stringify(data)}\n\n`)
}
// Send initial state
sendEvent('connected', { timestamp: Date.now() })
// Clean up on disconnect
req.on('close', () => {
// Remove this client from your subscriber list
})
})
Where SSE Shines at Scale
I've used SSE for subscription verification flows where the client needs to know the moment a payment has been confirmed on the backend. The user completes a payment, the frontend opens an SSE connection and the moment the backend confirms the transaction, it pushes an event. The client reacts immediately.
No polling loop hammering the endpoint. No WebSocket infrastructure. Just an HTTP connection that the server writes to when something happens.
SSE is the right tool for: notifications, live feed updates, subscription and payment status, progress tracking for long-running operations, any scenario where data flows server-to-client and you don't need the client to send messages back in real time.
The one real limitation: SSE is one-directional. The client cannot send data back over the same connection. If you need bidirectional communication, you need WebSockets.
WebSockets - When You Actually Need Them
WebSockets give you a full-duplex connection. Both the client and server can send messages at any time over a single persistent connection. That's genuinely powerful and genuinely more complex to operate.
class WebSocketClient {
constructor(url) {
this.url = url
this.socket = null
this.reconnectAttempts = 0
this.maxReconnectAttempts = 5
this.listeners = new Map()
}
connect() {
this.socket = new WebSocket(this.url)
this.socket.onopen = () => {
console.log('WebSocket connected')
this.reconnectAttempts = 0
}
this.socket.onmessage = (event) => {
const message = JSON.parse(event.data)
const handler = this.listeners.get(message.type)
if (handler) handler(message.payload)
}
this.socket.onclose = () => {
this.reconnect()
}
this.socket.onerror = (err) => {
console.error('WebSocket error', err)
}
}
send(type, payload) {
if (this.socket?.readyState === WebSocket.OPEN) {
this.socket.send(JSON.stringify({ type, payload }))
}
}
on(type, handler) {
this.listeners.set(type, handler)
}
reconnect() {
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
console.error('Max reconnect attempts reached')
return
}
const delay = Math.min(1000 * 2 ** this.reconnectAttempts, 30000)
this.reconnectAttempts++
setTimeout(() => this.connect(), delay)
}
disconnect() {
this.socket?.close()
}
}
WebSockets are the right tool for:
- Chat and messaging - messages flow in both directions in real time.
- Collaborative editing - multiple users editing the same document simultaneously.
- Live gaming - state needs to sync between players with minimal latency.
- Live auctions or trading - both the server and client are pushing updates constantly.
What most tutorials don't tell you about WebSockets:
Load balancers need sticky sessions or a pub/sub layer. A WebSocket connection is stateful - it lives on one server instance. If you're running multiple server instances behind a load balancer, a message published on instance A won't reach clients connected to instance B unless you have a message broker like Redis pub/sub in between.
At scale, this infrastructure cost is real. It's manageable, but it's not free, and it's worth knowing about before you commit to WebSockets for a use case that SSE would have handled.
Reconnection - The Part Everyone Gets Wrong
Every real-time implementation needs to handle disconnections. Networks drop. Servers restart. Mobile users switch between WiFi and cellular. If your reconnection logic is wrong, users silently stop receiving updates with no indication that anything is broken.
A few things that matter:
Exponential backoff with jitter. Don't retry on a fixed interval. If your server goes down and 100,000 clients all retry every 5 seconds in perfect synchrony, they'll create a thundering herd the moment the server comes back up.
function getReconnectDelay(attempt) {
const base = Math.min(1000 * 2 ** attempt, 30000)
const jitter = Math.random() * 1000
return base + jitter
}
The jitter spreads reconnection attempts across time so your server isn't hit by a simultaneous spike.
Track the last received event. For SSE, the browser does this for you with the Last-Event-ID header. For WebSockets, you need to implement this yourself. When you reconnect, tell the server where you left off so it can replay missed events.
Distinguish between clean closes and unexpected drops. A user navigating away is a clean close - don't reconnect. A network error is unexpected - do reconnect. Handle them differently.
Show the user when the connection is lost. A silent failure is worse than a visible one. If the real-time connection drops and doesn't recover quickly, tell the user. A small "reconnecting…" indicator is far better than them wondering why the live scores stopped updating.
Lessons From Building Real-Time at Scale
A few things you learn when real-time features are running for millions of users that don't show up in tutorials:
Connection count is a resource. Every open SSE or WebSocket connection holds state on the server. At scale, you need to think about connection limits, memory per connection and what happens when a deployment causes all connections to drop and reconnect simultaneously.
Health checks matter. Long-lived connections can appear alive to both sides while being silently broken in the middle - a dropped connection that neither end detected. Implement a heartbeat: the server sends a ping event every 30 seconds, the client expects it. If it doesn't arrive, reconnect.
// Server heartbeat
setInterval(() => {
clients.forEach(client => {
client.write('event: ping\ndata: {}\n\n')
})
}, 30000)
// Client - restart connection if no ping received
let lastPing = Date.now()
eventSource.addEventListener('ping', () => {
lastPing = Date.now()
})
setInterval(() => {
if (Date.now() - lastPing > 45000) {
eventSource.close()
reconnect()
}
}, 10000)
Authentication on long-lived connections needs thought. Tokens expire. If a user's access token expires while they have an open WebSocket connection, how do you handle that? You need a mechanism to refresh the token and either send it over the existing connection or re-establish with a new one.
Test with realistic concurrency. A real-time feature that works perfectly with 10 concurrent connections can behave completely differently with 10,000. Load test before you ship.
What Else Is Out There
WebRTC
WebRTC is a different category entirely. Where SSE and WebSockets are server-client protocols, WebRTC enables peer-to-peer communication directly between browsers. Audio, video and data can flow between clients without going through your server.
If you're building video calling, live audio or peer-to-peer file sharing, WebRTC is the right tool. It's not a replacement for SSE or WebSockets for server-push use cases - it solves a fundamentally different problem.
WebTransport
WebTransport is a newer protocol built on HTTP/3 and QUIC. It offers lower latency than WebSockets, supports both reliable and unreliable message delivery and handles connection migration better on mobile networks. It's promising, but browser support is still maturing. Worth keeping an eye on for the next few years.
GraphQL Subscriptions
If your stack uses GraphQL, subscriptions give you real-time updates through a familiar query interface. Under the hood they typically run over WebSockets. Useful if you're already invested in GraphQL and want real-time without adding a separate protocol layer.
How to Pick the Right Approach
Here's the decision framework I use:
Do you need the client to send messages to the server in real time?
- No - SSE is probably enough. Start there.
- Yes - WebSockets.
How frequently does data change?
- Every few minutes or less - polling with a sensible interval.
- Continuously or on server events - SSE or WebSockets.
What's your infrastructure complexity budget?
- Low - polling or SSE. Both work over standard HTTP.
- Higher - WebSockets, with the understanding that you'll need to handle load balancing and state carefully.
Do you need peer-to-peer communication?
- Yes - WebRTC.
- No - SSE or WebSockets.
Start simple. Polling is not embarrassing. SSE handles more use cases than most developers realize. WebSockets are powerful but come with operational overhead that you should only take on when you genuinely need what they offer.
The real-time feature that works reliably in production is always better than the one built with the most impressive technology.
Final Thoughts
Real-time on the frontend is not a single technology decision. It's a series of decisions, made per feature, based on what that feature actually needs.
SSE is HTTP-native, automatically reconnecting and dramatically simpler than WebSockets for one-directional data flows. Most real-time use cases are one-directional. Start there.
Use WebSockets when you genuinely need bidirectional communication. Understand the infrastructure implications before you commit.
And never underestimate polling for the right use case. At low frequency and low scale, it's often the most maintainable solution you can ship.
Pick the simplest tool that solves the problem. Your future self - and the engineer who maintains this after you - will thank you.
Have thoughts or questions on real-time frontend? Drop them in the comments, always happy to discuss.
This article is part of the Frontend at Scale series.
Top comments (0)