Real-Time Apps: WebSockets vs SSE vs WebRTC
“Make it real-time” is one of those requests that sounds simple and hides a dozen decisions. A live map of trucks, a chat thread, a collaborative document, a video call, a dashboard that updates itself. These all feel similar to a user, but under the hood they use very different tools, and the tool is the smaller decision. The larger ones are what happens when the connection drops, how you keep messages in order, and how you push one update to ten thousand people spread across many servers. This article covers the choice between WebSockets, Server-Sent Events and WebRTC, and then the unglamorous engineering that turns a working demo into a feature people rely on.
The short answer
Match the transport to the direction and the kind of data. If data only flows from server to client, use Server-Sent Events. If both sides talk continuously, use WebSockets. If you need live audio or video, or a direct connection between two users' devices, use WebRTC. And if the data changes every few seconds rather than every few milliseconds, honest polling over plain HTTP is a legitimate answer that a surprising number of teams are embarrassed to choose. The rest of this piece is the reasoning, because the summary alone will not get you through the first production incident.
The three transports, side by side
| Dimension | Server-Sent Events | WebSockets | WebRTC |
|---|---|---|---|
| Direction | Server to client only | Both ways, full duplex | Peer to peer (or peer to media server) |
| Underlying protocol | Plain HTTP response that never ends | Upgraded TCP connection with its own framing | UDP-based media and data channels, negotiated over a separate signaling path |
| Payload | Text events | Text or binary messages | Audio, video, arbitrary binary |
| Reconnection | Built into the browser, with Last-Event-ID | You write it | Renegotiate the whole session |
| Works through proxies and CDNs | Usually, since it is ordinary HTTP | Mostly; some corporate proxies still interfere | Needs STUN and TURN to traverse NAT |
| Infrastructure you must run | Your normal web servers | Stateful connection servers plus a pub/sub layer | Signaling, STUN, TURN, and often an SFU |
| Typical fit | Tickers, notifications, dashboards, streamed tokens from a language model | Chat, presence, collaborative editing, multiplayer state | Calls, screen sharing, low-latency device-to-device data |
Server-Sent Events
SSE is the tool people forget exists, and it is often the right one. The server answers an ordinary HTTP request with a response it simply never finishes, writing small text events as they happen. The browser's built-in EventSource reconnects on its own and sends the id of the last event it saw, so a server that keeps a short buffer can replay what the client missed with no custom code. It travels through load balancers and CDNs because, to them, it is just a slow response. The live fleet console we built for Vexolis streams truck telemetry to dispatchers exactly this way: trucks report position to the backend, the backend pushes updates down, and the browser never needs to send anything back on that channel. Streaming tokens from a language model to a chat interface is the same shape, which is why most AI products use SSE for it. The limits are real but narrow: it is one-directional, text-only, and older HTTP/1.1 browsers cap the number of open connections per domain, a constraint HTTP/2 removed.
WebSockets
A WebSocket starts life as an HTTP request that asks to be upgraded, and once the server agrees, both sides hold an open, persistent, two-way channel with very little per-message overhead. This is the workhorse of real-time features where the client is also talking: chat, live cursors, presence, a shared whiteboard, anything with a “who is typing” indicator. The cost is that the connection is stateful. A conventional web server handles a request and forgets it; a WebSocket server remembers every open client, which changes how you deploy, scale and restart it. A rolling deploy that recycles servers will disconnect every client on each one, so reconnection is not an edge case, it is a daily event you schedule yourself.
WebRTC
WebRTC is the heavy machinery, built for audio, video and direct peer-to-peer connections between users' devices. It is what powers video calls, and it is genuinely complex. Reach for it only when you need live media or true device-to-device data with the lowest possible latency, not for “fast chat”. It also comes with the detail that surprises teams: peer-to-peer still needs servers. Two devices cannot find each other without a signaling channel (usually a WebSocket) to exchange connection offers, and most of them sit behind home routers or office firewalls, so you need STUN servers to discover public addresses and TURN servers to relay traffic when a direct path cannot be punched through, which on restrictive networks is often. TURN relays the entire media stream, so it is the bandwidth line item that first-time WebRTC projects forget to budget. Group calls add one more piece: beyond three or four participants, a full mesh where everyone sends to everyone melts phones, so you route through a selective forwarding unit (SFU) that receives each stream once and forwards it. At that point you are running a media server, and a hosted provider is usually the sane choice unless calls are the product.
Data flows down only: SSE. Both directions: WebSockets. Live audio, video or peer-to-peer: WebRTC. Most apps need the middle one, and more of them than admit it would be fine with the first.
When polling is honestly fine
Before any of the above, ask how fresh the data actually needs to be. A dashboard that refreshes every fifteen seconds, an order status that changes a few times an hour, a queue position that moves once a minute: these do not need a persistent connection. A plain GET on an interval, with an ETag so unchanged data costs almost nothing, uses your existing stateless servers, caches at the CDN, survives every proxy, and has no reconnection logic because there is nothing to reconnect. It scales exactly like the rest of your site. The rule we use: if a user would not notice a delay of a few seconds, poll. If they would notice, but only when something happens rarely, use SSE. Only when the interface is genuinely live, with updates every second or faster and traffic in both directions, does a WebSocket earn its operational cost. Real-time is a property of the experience, not of the transport, and the boring option is often the one that ships and stays up.
The hard part is not opening the connection
Getting a single WebSocket working is an afternoon. Making it reliable at scale is the real work, and it splits into three problems: surviving disconnects, keeping messages ordered and complete, and fanning one message out to many servers.
Reconnection that does not take down your server
Connections drop on mobile networks constantly: an elevator, a tunnel, a switch from Wi-Fi to cellular. The client must reconnect automatically, and the naive way to do it causes an outage. If your server restarts and ten thousand clients all retry after exactly one second, they arrive as a single wave, the server falls over again, and the cycle repeats. That is a thundering herd, and the fix is exponential backoff with jitter: wait longer after each failure, and randomize the wait so the herd spreads out. You also need heartbeats. A half-open connection, where the network died but neither side has noticed, looks exactly like a quiet one. Pinging every twenty or thirty seconds and treating a missed pong as a dead connection is how you learn the difference before the user does.
// Reconnect with exponential backoff and jitter, then resume from the last seen id
let attempt = 0;
function connect() {
const ws = new WebSocket(URL);
ws.onopen = () => {
attempt = 0;
ws.send(JSON.stringify({ type: "resume", lastSeenId }));
};
ws.onmessage = (e) => {
const msg = JSON.parse(e.data);
if (msg.id <= lastSeenId) return; // duplicate after a replay, ignore
lastSeenId = msg.id;
apply(msg);
};
ws.onclose = () => {
const base = Math.min(30_000, 500 * 2 ** attempt++);
setTimeout(connect, base / 2 + Math.random() * base / 2);
};
}Ordering, gaps and duplicates
A reconnect solves the connection but not the data. Whatever happened during the gap is gone unless you design for it. The pattern that works is to give every message a monotonically increasing id per stream, have the client remember the last one it applied, and let it ask the server to resume from there. The server keeps a short replay buffer (a Redis stream or a small table is enough) and sends everything newer. If the gap is longer than the buffer, the client falls back to a full snapshot over ordinary HTTP and then resumes live. Two consequences follow. Messages must be idempotent, because a replay can redeliver one the client already saw, so “set the truck's position to X” is safe and “add 1 to the counter” is not. And a one-way stream should never be the only source of truth: the live channel makes the interface feel alive, but a page reload must be able to rebuild the state from a normal API. Design the snapshot first and the stream second, and reconnection stops being scary.
Scaling fan-out across servers
One server can hold many open connections, but not all of them, and the moment you have two servers a message sent by a user on server A must reach users on server B. The standard answer is a pub/sub layer behind the connection servers: each server subscribes to the channels its clients care about, publishes every incoming message to Redis (or NATS, or a managed equivalent), and broadcasts what it receives to its own local sockets. The connection servers stay simple and replaceable, and the pub/sub layer is the only thing that has to know about everyone. Two design rules keep this cheap. First, subscribe by topic, not by user: a fleet console subscribes to “fleet:42”, and a truck update is published once, not once per dispatcher watching it. Second, coalesce. If a sensor reports ten times a second and a human is watching a map, sending every reading is wasted work; batch updates into a frame every 200 milliseconds and send the latest state, and your fan-out drops by an order of magnitude with no visible difference. Sticky sessions at the load balancer, so a reconnecting client lands on the server that still holds its state, are a convenience, not a requirement; a design that resumes from an id works on any server.
Presence is harder than it looks
“Who is online” seems trivial: a user connects, mark them online; they disconnect, mark them offline. In practice one user has three tabs and a phone, connections flap every few minutes on mobile, and a server that crashes never sends the disconnect. The workable model is a set of connections per user with a time-to-live: each heartbeat refreshes the entry, a user is online while at least one entry is fresh, and a crashed server's entries simply expire. Debounce the offline transition by a few seconds so a reconnect does not flash “offline” to everyone in the room. It is small, but presence bugs are the most visible real-time bugs a product ships.
Build or buy
Everything above is well understood and largely solved by libraries and managed platforms, and for most teams that is the right purchase. A hosted real-time service or a mature library handles reconnection, replay, presence and horizontal scaling so you do not rebuild them from scratch; the cost is a per-connection or per-message bill and a dependency you cannot patch yourself. The decision is the same one we describe for adding AI to a product: buy the plumbing when the feature is a supporting one, build it when real-time is the product or when the data volume makes the per-message pricing your biggest cloud line. Our rule of thumb: start with SSE on your existing servers if the direction allows it, move to a managed WebSocket layer when the client needs to talk, and run your own only once you can name the reason.
A decision framework you can run in an hour
- Which direction does data flow? Down only points to SSE; both ways points to WebSockets.
- How fresh is fresh enough? Seconds means polling or SSE; sub-second means a persistent connection.
- Is there live audio or video? Yes means WebRTC, plus a signaling channel and a TURN budget.
- Can the client rebuild state from a snapshot? If not, design that API before the stream.
- Are messages idempotent? If not, make them so; replay and duplicates are guaranteed.
- How many servers? More than one means a pub/sub layer from day one, not after the first incident.
- Is real-time the product? If not, a managed platform is almost certainly cheaper than your own.
The bottom line
Match the tool to the data flow, then spend your energy on the unglamorous parts: reconnection with backoff, resuming from a last-seen id, idempotent messages and topic-based fan-out. That is what separates a demo from a feature people trust, and it is the difference we watched matter in production on Vexolis, where dispatchers judge the whole product by whether the map is telling the truth. Real-time is one part of the web platforms and custom systems we build. If you are designing one and want a second opinion on the transport or the scaling plan, tell us what you are building, or browse our work.
Frequently asked questions
Should I use WebSockets or Server-Sent Events?
Decide by direction. If data only flows from the server to the client, such as a live map, a notification feed, a dashboard or tokens streamed from a language model, Server-Sent Events are simpler: they run over ordinary HTTP, pass through load balancers and CDNs, and the browser reconnects and resumes from the last event id on its own. If the client also needs to send frequent messages, such as chat, live cursors, presence or collaborative editing, use WebSockets, which give you a persistent two-way channel at the cost of stateful servers and reconnection logic you write yourself.
When is polling good enough instead of a real-time connection?
Whenever a user would not notice a delay of a few seconds. Order status, a queue position, a dashboard that refreshes every fifteen seconds: a plain GET on an interval with an ETag uses your existing stateless servers, caches at the CDN, survives every proxy and has no reconnection logic because there is nothing to reconnect. Persistent connections earn their operational cost only when the interface is genuinely live, with sub-second updates and traffic in both directions.
Does WebRTC really need servers if it is peer-to-peer?
Yes. Two devices cannot find each other without a signaling channel, usually a WebSocket, to exchange connection offers, and most devices sit behind home routers or office firewalls, so you need STUN servers to discover public addresses and TURN servers to relay traffic when a direct path cannot be established, which on restrictive networks is common. TURN relays the entire media stream, so it is a real bandwidth cost. Group calls beyond three or four people also need a selective forwarding unit, which means running or renting a media server.
How do I handle reconnection and missed messages in a WebSocket app?
Reconnect with exponential backoff and random jitter so ten thousand clients do not retry at the same instant and take the server down again, and use heartbeats to detect half-open connections. For the data, give every message an increasing id per stream, have the client remember the last id it applied and ask the server to resume from there, keep a short replay buffer on the server, and fall back to a full snapshot over ordinary HTTP if the gap is longer than the buffer. Messages must be idempotent, because replays will redeliver some of them.
How do real-time features scale across multiple servers?
Put a pub/sub layer such as Redis or NATS behind the connection servers. Each server subscribes to the topics its clients care about, publishes incoming messages once, and broadcasts what it receives to its own local sockets, so the connection servers stay simple and replaceable. Subscribe by topic rather than per user so one update is published once regardless of how many people are watching, and coalesce high-frequency updates into a frame every few hundred milliseconds, which cuts fan-out by an order of magnitude with no visible difference.
Should I build my own real-time infrastructure or use a managed service?
Use a managed platform or a mature library unless real-time is the product itself or your message volume makes per-message pricing your largest cloud line. They handle reconnection, replay, presence and horizontal scaling, and the cost is a per-connection or per-message bill and a dependency you cannot patch yourself. A sensible path is to start with Server-Sent Events on your existing servers when the direction allows it, move to a managed WebSocket layer when the client needs to talk, and run your own only once you can name the reason.
Thinking about building this?
Appluex designs and ships production mobile & web apps. Including AI features. Let's talk.