The code samples are deliberately compact. In production, add structured logging, metrics and load testing before you trust any of them.
"Real-time" is three different problems
When a product manager says "it should update in real time", they might mean one of three quite different things. Naming which one you need is half the engineering:
- The server must push updates to the browser — a live order status, notifications, a dashboard, a progress bar. Data flows mostly one way.
- Browser and server exchange messages continuously in both directions — chat, collaborative editing, multiplayer games, remote control of a device. Data flows both ways, often at high frequency.
- Data should simply be fresh enough — a stock price every ten seconds is not "real time" in any meaningful sense.
The transport you choose should follow from which problem you have. Reaching for WebSockets by default is the most common over-engineering mistake in this space.
The three contenders
Polling: ask again and again
The client asks the server "anything new?" on a timer.
async function poll() {
const res = await fetch("/api/status");
render(await res.json());
setTimeout(poll, 5000);
}
poll();
Pros: trivial, works everywhere, stateless, plays perfectly with caches and CDNs. Cons: wasted requests when nothing changed, latency up to the interval, and shortening the interval multiplies server load.
Long polling improves it: the server holds the request open until there is something to send (or a timeout), then the client immediately asks again. Latency drops to near zero, but every open request holds a connection.
Server-Sent Events (SSE): a one-way firehose over plain HTTP
SSE is a standard browser feature: the client opens one long-lived HTTP response with Content-Type: text/event-stream, and the server writes events into it as they occur. The browser's EventSource API handles automatic reconnection and even resumes from the last event ID.
Pros: simple, plain HTTP (proxies, auth cookies and HTTP/2 multiplexing just work), built-in reconnection. Cons: server-to-client only, text only, and long-lived connections still count against your platform's limits.
WebSockets: a persistent two-way channel
A WebSocket starts as an HTTP request that is upgraded to a persistent, full-duplex connection. Either side can send frames at any time, in text or binary, with very low overhead per message.
Pros: bidirectional, low latency, binary support, ideal for high-frequency traffic. Cons: you own the reconnection logic, heartbeats and back-pressure; they need a server that can hold connections open; and scaling past one server requires a message broker.
A decision table
| Need | Best fit |
|---|---|
| Data changes every minute or slower | Polling |
| Notifications, live feed, progress, dashboards (server → client) | SSE |
| Chat, collaboration, games, device control (both directions) | WebSocket |
| Occasional server events but you cannot host a long-lived process | Managed pub/sub (Pusher, Ably, Supabase Realtime, Firebase) |
| Browser must control hardware at 20–60 updates per second | WebSocket, binary or compact JSON |
A useful rule of thumb: if the client only listens, use SSE. Most "real-time" features people build with WebSockets would be simpler, cheaper and easier to debug as SSE.
SSE in a Next.js route handler
Route handlers can return a streamed Response. Here is a minimal, correct SSE endpoint:
// app/api/events/route.ts
export const dynamic = "force-dynamic";
export async function GET(req: Request) {
const encoder = new TextEncoder();
const stream = new ReadableStream({
start(controller) {
const send = (event: string, data: unknown) =>
controller.enqueue(
encoder.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`),
);
send("hello", { connectedAt: Date.now() });
// Comment lines (starting with ":") keep proxies from closing an idle connection.
const keepAlive = setInterval(
() => controller.enqueue(encoder.encode(": keep-alive\n\n")),
15000,
);
const tick = setInterval(() => send("tick", { t: Date.now() }), 1000);
req.signal.addEventListener("abort", () => {
clearInterval(keepAlive);
clearInterval(tick);
controller.close();
});
},
});
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache, no-transform",
Connection: "keep-alive",
},
});
}
And the client, with almost no code:
const source = new EventSource("/api/events");
source.addEventListener("tick", (e) => console.log(JSON.parse(e.data)));
source.onerror = () => console.warn("Disconnected — the browser will retry.");
Two practical notes. First, the format is strict: each event ends with a blank line (\n\n). Second, a proxy that buffers responses (some reverse proxies and CDNs do) will silently ruin SSE — set X-Accel-Buffering: no for nginx, and test behind your real infrastructure.
WebSockets: the part where you must not be naive
The browser API looks friendly:
const socket = new WebSocket("wss://example.com/ws");
socket.onmessage = (event) => console.log(event.data);
socket.send(JSON.stringify({ type: "hello" }));
The trouble is everything the API does not do for you.
1. Reconnect with exponential backoff and jitter
Connections drop constantly: mobile networks switch, laptops sleep, servers deploy. A client that retries instantly and forever will hammer a recovering server into the ground — every client at the same moment. Use exponential backoff with random jitter:
type Handlers = { onMessage: (data: unknown) => void; onStatus?: (s: string) => void };
export function connect(url: string, { onMessage, onStatus }: Handlers) {
let socket: WebSocket | null = null;
let attempt = 0;
let closedByUser = false;
const open = () => {
socket = new WebSocket(url);
socket.onopen = () => {
attempt = 0; // healthy again
onStatus?.("open");
};
socket.onmessage = (e) => onMessage(JSON.parse(e.data));
socket.onclose = () => {
onStatus?.("closed");
if (closedByUser) return;
const base = Math.min(30_000, 500 * 2 ** attempt++); // 0.5s, 1s, 2s … 30s
const delay = base / 2 + Math.random() * (base / 2); // jitter
setTimeout(open, delay);
};
};
open();
return {
send: (msg: unknown) =>
socket?.readyState === WebSocket.OPEN && socket.send(JSON.stringify(msg)),
close: () => {
closedByUser = true;
socket?.close();
},
};
}
2. Heartbeats: detect dead connections
A TCP connection can be "half-dead" — one side vanished (a phone entered a tunnel) and the other never got a close. Without a heartbeat you will keep "sending" into a black hole. The server pings on an interval and terminates clients that do not answer:
// server.ts — Node.js with the "ws" package
import { WebSocketServer, WebSocket } from "ws";
const wss = new WebSocketServer({ port: 8080 });
const alive = new WeakMap<WebSocket, boolean>();
wss.on("connection", (ws) => {
alive.set(ws, true);
ws.on("pong", () => alive.set(ws, true));
ws.on("message", (raw) => {
const msg = JSON.parse(raw.toString());
// ...validate `msg` before trusting it!
for (const client of wss.clients) {
if (client !== ws && client.readyState === WebSocket.OPEN) {
client.send(JSON.stringify(msg));
}
}
});
});
setInterval(() => {
for (const ws of wss.clients) {
if (!alive.get(ws)) { ws.terminate(); continue; }
alive.set(ws, false);
ws.ping();
}
}, 30_000);
3. Authenticate — and remember the browser API has no headers
The browser WebSocket constructor cannot set custom headers such as Authorization. The clean options are:
- Rely on the session cookie — it is sent automatically with the upgrade request on the same site. Validate the
Originheader on the server to prevent cross-site WebSocket hijacking. - Send a short-lived, single-use ticket obtained from a normal authenticated HTTP endpoint and pass it in the query string or the first message.
Avoid putting long-lived tokens in the URL — URLs end up in logs.
4. Treat every incoming message as hostile input
A WebSocket message is user input like any other. Validate its shape, cap its size (maxPayload in ws), rate-limit per connection, and never build queries or commands by concatenating fields.
5. Watch back-pressure
If a client reads slower than you send, data piles up in memory (ws.bufferedAmount grows). For high-frequency streams, drop or coalesce stale updates — for a live position marker, only the latest value matters.
Scaling beyond one server
A WebSocket connection lives on one specific server process. As soon as you run two instances, a message from a client on server A has no path to a client on server B. The standard architecture adds a pub/sub broker:
- Every server subscribes to a channel (for example a Redis channel per chat room).
- When a server receives a message, it publishes it to the broker instead of broadcasting directly.
- Every server, including the sender, receives it from the broker and forwards it to its local clients.
Libraries such as the Socket.IO Redis adapter package this pattern. Also plan for sticky sessions or a load balancer that understands the upgrade handshake, and remember each open connection costs memory — measure how many one instance can hold.
What about serverless (Vercel, Netlify)?
Here is the honest answer that saves days of frustration: standard serverless functions cannot host WebSocket servers. They are short-lived, they scale to zero, and they cannot keep a connection open indefinitely.
Your realistic options:
- Run the WebSocket server as a separate long-running process (Railway, Fly.io, Render, a VPS) and point the frontend at it while keeping the rest of the site on serverless.
- Use a managed real-time service (Pusher, Ably, Supabase Realtime, Firebase). You give up some control and pay per connection or message, but you inherit scaling, reconnection and global edge presence.
- For one-way updates, use SSE or short polling, which serverless handles far better — subject to the platform's maximum function duration.
Message design: cheap now, priceless later
Decide on a small envelope for every message from day one:
{ "v": 1, "type": "cursor.move", "id": "a1b2", "ts": 1700000000000, "data": { "x": 10, "y": 20 } }
vlets you evolve the protocol without breaking old clients.typemakes routing explicit and logging useful.idallows acknowledgements and de-duplication after a reconnect.- For high-rate traffic (telemetry, games), send compact arrays or binary frames instead of verbose JSON — the parsing and bandwidth savings add up fast.
Test the ugly paths
The happy path always works on localhost. Before shipping, deliberately break things:
- Kill the server while clients are connected — do they reconnect politely, or stampede?
- Toggle airplane mode; close the laptop lid for a minute.
- Send malformed, oversized and extremely frequent messages.
- Open a few hundred connections with a load-testing tool and watch memory.
- Deploy a new version mid-session — do users notice?
Summary
- Ask which direction the data flows and how often. Listen-only? Use SSE. Two-way and fast? Use WebSockets. Slow-changing? Poll.
- WebSockets need reconnection with backoff and jitter, heartbeats, authentication, validation and back-pressure handling. None of it is optional.
- Scaling out requires a pub/sub broker; serverless platforms need a separate WebSocket host or a managed service.
- Version your messages from the start.
Real-time features feel like magic to users precisely because the engineering underneath is careful about failure. Build for the disconnects and the magic will look effortless.