Full-Stack Questions
Realtime: WebSockets, SSE and Socket.IO
Polling vs SSE vs WebSocket, handshake and heartbeats, Socket.IO rooms and acks, scaling with Redis, auth, backpressure and proxy timeouts.
On this page
- Words used in this lesson
- The four ways to get news from a server
- How a WebSocket starts: the handshake
- A minimal WebSocket server with heartbeat
- Heartbeats and dead connections
- Reconnecting: the client's job
- Server-Sent Events (SSE)
- Backpressure on WebSockets
- Socket.IO: what it is and is not
- Scaling to many servers
- Authentication and authorization on sockets
- Proxies, timeouts and the network in the middle
- Serverless limits
- Presence ("who is online")
- Common mistakes
- Interview questions
- Key takeaways
In one line: "realtime" means the server can tell the browser something the moment it happens, without the browser asking. You have four tools: polling, long polling, Server-Sent Events (one-way push) and WebSockets (two-way). Choosing one is easy. The hard part is what comes after: dead connections, reconnects, lost messages, authentication, many servers, proxies and slow clients. Those are the questions a 4+ year engineer must answer.
Most web traffic is "ask and answer": the browser sends a request, the server replies, and the conversation ends. Chat, live prices, notifications and "someone is typing" need the opposite: the server speaks first. This lesson shows the options, then the problems that appear in production, with code that was run with ws 8, Socket.IO 4.8 and Redis 7. The choice between SSE and WebSocket for a notification bell is already covered in Practical Design Problems, Lesson 05. Here we go deeper into the protocols, Socket.IO, scaling and operations.
Words used in this lesson
- A connection is an open line between the browser and the server that stays alive after the first message. A socket is one end of such a line.
- TCP is the basic network protocol under HTTP and WebSocket. It delivers bytes in order and resends lost ones. TLS is the encryption on top of it (
https://andwss://). - Polling is when the browser asks "anything new?" again and again on a timer. Like checking your mailbox every five minutes.
- Long polling is polling where the server holds the request open until it has news (or a timeout), then answers; the browser asks again at once. Like asking a friend "tell me when it rings" and waiting by the phone.
- SSE (Server-Sent Events) is a browser feature where one HTTP response never ends and the server keeps writing text events into it. The browser class is
EventSource. It is one-way: server to browser. - WebSocket is a protocol that starts as an HTTP request and is then "upgraded" into a two-way connection where both sides send messages (text or binary) at any time.
- A frame is one small packet of the WebSocket protocol. A message is made of one or more frames.
- A heartbeat is a small message sent on a timer just to prove the line is alive. Ping/pong is the WebSocket version: one side sends a ping frame and the other must answer with a pong.
- A proxy (or reverse proxy, such as nginx) is a server between the browser and your app. A load balancer spreads connections over many app servers.
- Backpressure is how a slow receiver tells a fast sender to slow down. Like a funnel: pour slower when it fills up.
- Pub/Sub (publish/subscribe) is a messaging style where publishers send to a named channel and every subscriber of that channel gets a copy. Redis has it built in. It does not store messages.
- Idempotent means doing the same thing twice has the same effect as doing it once.
The four ways to get news from a server
| Method | How it works | Direction | Cost per update | Good for |
|---|---|---|---|---|
| Polling | Repeat GET every N seconds | Browser asks | A full request each time, mostly "nothing new" | Slow data (a job status every 5 s), simplest to build |
| Long polling | GET held open until news or timeout | Browser asks, server answers late | A new request per update | Old browsers, strict networks, fallback |
| SSE | One GET, response never ends | Server to browser only | Almost nothing after the start | Notifications, live feeds, progress, AI token streams |
| WebSocket | HTTP Upgrade, then own protocol | Both ways | A few bytes per message | Chat, games, collaborative editing, anything the client also sends often |
Difference in one line: SSE is a radio (the server talks, you listen, and it tunes back in by itself if the signal drops); a WebSocket is a phone call (both talk, and you must redial yourself).
How to choose, in the order I think:
- Does the client also send frequent small messages? If no, SSE is usually enough. If yes, WebSocket.
- Is binary data needed (audio, game state)? WebSocket. SSE is text only.
- Do I need to pass through strict corporate proxies? SSE is plain HTTP and passes more easily.
- What does my host allow? Some platforms cannot hold a WebSocket open at all (see the serverless section).
- Is the update rate low? Then polling every few seconds may be the cheapest honest answer.
How a WebSocket starts: the handshake
The browser sends a normal HTTP GET that asks to switch protocols. I ran this against a ws server and this is the real answer:
GET /?token=good HTTP/1.1Upgrade: websocketConnection: UpgradeSec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==Sec-WebSocket-Version: 13HTTP/1.1 101 Switching ProtocolsUpgrade: websocketConnection: UpgradeSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=101 Switching Protocolsmeans "this TCP connection now speaks WebSocket, not HTTP".Sec-WebSocket-Keyis a random value the client sends. The server hashes it with a fixed string and returns it asSec-WebSocket-Accept. This proves the server really understands WebSocket. It is not security or login.- A subprotocol (
Sec-WebSocket-Protocol) is an optional name for the message format on top, such asgraphql-ws. - After the
101, there is no more request and response. Both sides send frames. Cookies and headers are sent once, in the handshake, not on every message.
The browser WebSocket class cannot set custom headers such as Authorization. It can only set the URL and subprotocols. Cookies are sent by the browser automatically. This one fact drives every WebSocket authentication design (see below).
A minimal WebSocket server with heartbeat
ws is the most used WebSocket library for Node.js. It gives you the plain protocol and nothing more: no rooms, no reconnect, no acknowledgements.
import { WebSocketServer } from 'ws';import http from 'node:http';const server = http.createServer();const wss = new WebSocketServer({ noServer: true, maxPayload: 1024 * 1024 }); // 1 MiBserver.on('upgrade', (req, socket, head) => { const token = new URL(req.url, 'http://x').searchParams.get('token'); if (!isValid(token)) { // authenticate BEFORE upgrading socket.write('HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n'); return socket.destroy(); } wss.handleUpgrade(req, socket, head, (ws) => wss.emit('connection', ws, req));});wss.on('connection', (ws) => { ws.on('error', (e) => console.log('socket error', e.code)); // never skip this ws.isAlive = true; ws.on('pong', () => (ws.isAlive = true)); ws.on('message', (data, isBinary) => ws.send(data, { binary: isBinary }));});setInterval(() => { // heartbeat every 30 s wss.clients.forEach((ws) => { if (!ws.isAlive) return ws.terminate(); // missed the last ping: drop it ws.isAlive = false; ws.ping(); });}, 30_000);Three details I tested, because each one has hurt real teams:
- No error listener, process crash. When a client sent a message bigger than
maxPayload,wsclosed the connection with code1009("message too big") and also emitted anerrorevent on the server-side socket. With nows.on('error'), Node throws an unhandlederrorevent and the whole process dies. One bad client could take the server down. maxPayloaddefaults to 100 MiB inws. That is far too big for chat. Set a limit that fits your messages.perMessageDeflate(compression of each message) is off by default on thewsserver. It costs a lot of memory and CPU per connection, so turn it on only if you measured a need.
Heartbeats and dead connections
A connection can die without anybody being told. A laptop lid closes, a phone goes into a tunnel, a router drops the line. TCP does not notice for a long time, and the server keeps a "ghost" socket.
So both sides need a heartbeat:
- The server sends a ping every N seconds and marks the socket "waiting".
- A pong clears the mark. If the next round starts and the mark is still there, the server calls
terminate()(destroy now) and notclose()(polite goodbye that waits for a reply that will never come). - Browsers answer protocol pings automatically, but browser code cannot send a ping frame. So for the client side of detection, applications often send their own small
{"type":"ping"}message and expect a reply.
I tested this with a client that never answered pings. The server removed it on the second round. Heartbeats also keep proxies from closing the connection for being idle (more on that below).
Reconnecting: the client's job
WebSocket in the browser never reconnects by itself. EventSource does. If you use raw WebSockets you must write the loop. The important rule: wait longer after each failure, and add randomness, or after a server restart every client comes back at the same second and knocks the server down again (a thundering herd).
function connectWithRetry(url, onMessage) { let attempt = 0, closedByUser = false, ws; const base = 100, cap = 30_000; const open = () => { ws = new WebSocket(url); ws.onopen = () => { attempt = 0; }; ws.onmessage = (e) => onMessage(e.data); ws.onclose = () => { if (closedByUser) return; const delay = Math.random() * Math.min(cap, base * 2 ** attempt++); // full jitter setTimeout(open, delay); }; }; open(); return { close() { closedByUser = true; ws.close(); } };}This is exponential backoff (the wait doubles each time) with full jitter (a random wait between 0 and the limit). I killed the server and restarted it: the client retried after 88 ms, 163 ms and 287 ms and then got back in.
Reconnecting is only half of the job. While the client was away, it missed messages. Neither raw WebSocket nor Socket.IO fixes that for you by default (see the delivery section).
Server-Sent Events (SSE)
The stream is plain text. Each event is a few field: value lines, ended by a blank line:
retry: 500id: 3event: msgdata: {"text":"hello"}: pingdata:is the payload.event:names the event type (default ismessage).id:is the event id.retry:tells the browser how many milliseconds to wait before reconnecting. A line that starts with:is a comment, perfect for a heartbeat.- When the connection drops,
EventSourcereconnects by itself and sends the last id it saw in aLast-Event-IDrequest header. The server can use it to send what was missed. EventSourcecan only makeGETrequests and cannot set headers. For aPOSTbody or anAuthorizationheader you usefetchand read the stream yourself.- On HTTP/1.1, browsers allow only about 6 connections per domain, so several tabs with SSE can block each other. HTTP/2 removes this limit. Use HTTP/2 or HTTP/3 in production.
AI chat answers that appear word by word are usually streamed as SSE-style events over a normal POST with fetch. It is the same idea: one long response, written in pieces. See the AI SDK lesson for the library side.
SSE in a Next.js Route Handler
A Route Handler is a file named route.ts in the App Router that exports functions named after HTTP methods (GET, POST). It receives a Web Request and returns a Web Response. A Response can take a ReadableStream as its body, so you can write to it over time. This is the pattern in the Next.js docs ("Streaming in Route Handlers").
// app/api/stream/route.tsconst enc = new TextEncoder();const fmt = (e: { id: number; data: unknown }) => `id: ${e.id}\nevent: msg\ndata: ${JSON.stringify(e.data)}\n\n`;export async function GET(request: Request) { const lastId = Number(request.headers.get('last-event-id') ?? 0); let cleanup = () => {}; const stream = new ReadableStream({ start(controller) { const send = (e: { id: number; data: unknown }) => controller.enqueue(enc.encode(fmt(e))); subscribers.add(send); // 1. subscribe first for (const e of log.filter((e) => e.id > lastId)) send(e); // 2. then replay what was missed const hb = setInterval(() => controller.enqueue(enc.encode(': ping\n\n')), 15_000); cleanup = () => { clearInterval(hb); subscribers.delete(send); }; request.signal.addEventListener('abort', () => { cleanup(); try { controller.close(); } catch {} }); }, cancel() { cleanup(); }, }); return new Response(stream, { headers: { 'Content-Type': 'text/event-stream; charset=utf-8', 'Cache-Control': 'no-cache, no-transform', 'X-Accel-Buffering': 'no', // tell nginx not to hold the stream back }, });}I ran this handler with a real EventSource client. I closed every connection while two events were published. After the automatic reconnect with Last-Event-ID, the client had all four events: 1, 2, 3, 4. And the cleanup ran, so the subscriber list went back to zero.
Why each part is there:
- Subscribe first, replay second. If you replay first, an event that arrives in the gap is lost. The cost: one event can appear twice, so the client should ignore ids it already has.
- Cleanup on abort and cancel. When the user closes the tab, the stream must stop. If you forget, timers and subscribers pile up: a memory leak that grows with every visit.
- Heartbeat comment. It keeps proxies from closing a quiet stream.
X-Accel-Buffering: nois the nginx header that turns off response buffering. The Next.js self-hosting guide says that when you use nginx or a similar proxy, you must disable buffering to make streaming work, and that load balancers must not buffer chunked responses either.- Route Handlers are not cached by default, so a
GETstream does not get frozen by the cache. Do not adddynamic = 'force-static'to it.
The stream is the fast path, not the source of truth. Keep events in a database (or a log) with ids. The stream only delivers them quickly. Without a store there is nothing to replay after a drop.
Backpressure in SSE and plain streams
When you res.write() in Node.js and it returns false, the internal buffer is full. You should wait for the drain event before writing more, or memory grows without limit when a client is slow. With Web ReadableStream, the same idea exists as controller.desiredSize (a number that goes down to zero or below when the consumer is behind).
Backpressure on WebSockets
On a WebSocket, ws.send() never blocks. It puts the data in a queue. If the client reads slowly (bad mobile network), the queue grows in your server's memory. ws.bufferedAmount tells you how many bytes are queued but not yet sent.
const MAX_BUFFERED = 1_000_000; // 1 MB per connectionsetInterval(() => { if (ws.bufferedAmount > MAX_BUFFERED) return; // slow consumer: skip this update ws.send(frame);}, 5);I tested it with a client that stopped reading. The server sent 25 frames of 200 KB, then the buffer hit 1 MB and the code skipped the other 263 ticks. Without the check, memory would have grown for as long as the client stayed connected.
What to do with a slow client depends on the data:
- Latest value wins (prices, cursor position): skip or replace. Send only the newest.
- Every message matters (chat): keep a bounded queue; if it overflows, close the connection. The client reconnects and catches up from the database.
- Never keep an unbounded queue per connection.
Socket.IO: what it is and is not
Socket.IO is a library (server and client) that gives you a higher-level realtime API on top of WebSocket and HTTP long polling. It adds automatic reconnect, rooms, namespaces, acknowledgements, buffering and a way to work across many servers.
Socket.IO is not WebSocket. It has its own message format. A plain new WebSocket() cannot talk to a Socket.IO server, and a Socket.IO client cannot talk to a plain ws server. Choose one family on both sides.
It has two layers. Engine.IO is the low-level part: it chooses the transport, runs the heartbeat and detects disconnects. Socket.IO is the high-level part: events, rooms, acks.
- It starts with HTTP long polling, then upgrades to WebSocket. The reason in the docs: some firewalls and proxies block WebSockets, and a failed WebSocket attempt can make the user wait up to about ten seconds. I saw it in a test: at the
connectevent the transport waspolling, a moment later it waswebsocket. - The handshake gives the client a session id (
sid), the ping settings and a maximum packet size. The defaults I checked on the server:pingInterval25 000 ms,pingTimeout20 000 ms,maxHttpBufferSize1 000 000 bytes (1 MB), and compression off. - The heartbeat is reversed compared with
ws: the Socket.IO server sends the ping, and the client must send the pong back withinpingTimeout.
Rooms, namespaces, acknowledgements
- A room is a named group that sockets can
joinandleaveon the server. You can emit to everyone in a room. A room exists only on the server; the client cannot list its rooms. When a socket disconnects, it leaves all rooms automatically. - A namespace is a separate channel on the same connection, with its own events, middleware and rooms, such as
/admin. It splits features (admin tools vs chat). A room splits audiences inside one namespace. - An acknowledgement (ack) is a reply callback on one event: you emit, and the other side answers. It is like a function call over the network.
Difference in one line: a namespace is a different app on the same wire; a room is a mailing list inside one app.
io.use((socket, next) => { // runs once per connection const user = verify(socket.handshake.auth.token); if (!user) { const err = new Error('unauthorized'); err.data = { code: 'BAD_TOKEN' }; // the client can read err.data return next(err); } socket.data.userId = user.id; next();});io.on('connection', (socket) => { socket.join(`user:${socket.data.userId}`); // one room per user: reaches all their tabs socket.on('join-room', (room, cb) => { socket.join(room); cb({ ok: true }); }); socket.on('chat', (room, text, cb) => { socket.to(room).emit('chat', { from: socket.data.userId, text }); // everyone except sender cb({ delivered: true }); });});What I confirmed by running it:
- A bad token gave
connect_erroron the client with the message anddatafrom the middleware. socket.to(room).emit(...)reached the other member, not the sender.io.to(room).emit(...)includes everyone.- Emitting to the room
user:alicereached both of Alice's tabs. One room per user id is the standard trick. socket.timeout(100).emitWithAck('slow')rejected withoperation has timed outwhen the other side answered after 500 ms. Always use a timeout with acks; without one, a lost reply waits forever.- A namespace middleware that rejects the connection gave the error
admins onlyon/admin.
Delivery guarantees (the part people assume wrongly)
Socket.IO's docs are clear about what it promises:
- Order is guaranteed. Events sent in order arrive in order, even during the upgrade from polling to WebSocket.
- Delivery is "at most once" by default. If the connection breaks while an event is in flight, it may be lost and is not retried. A disconnected client buffers its outgoing events until it reconnects. The server has no such buffer: events a disconnected client missed are not sent later.
- For client to server "at least once", set
retriesandackTimeouton the client. Even then, a page refresh loses pending events. - For server to client, you build it: give each event a unique id, store it in a database, and let the client send its last seen id (an offset) in
authwhen it reconnects. The server replays what is newer. This is the same idea asLast-Event-IDin SSE.
Connection state recovery is a built-in shortcut for short drops. You enable it on the server:
const io = new Server(httpServer, { connectionStateRecovery: { maxDisconnectionDuration: 2 * 60 * 1000 },});The server keeps the socket id, rooms and data for a while, and tags each packet with an offset. In my test I dropped the transport, and an event sent during the gap arrived after reconnecting, with socket.recovered === true. But:
- Recovery is not guaranteed. You still need a full resync path when
recoveredisfalse. - The server must have sent at least one event first, so the client has an offset.
- It works with the in-memory adapter, the Redis Streams adapter and the MongoDB adapter. The classic Redis adapter does not support it, because Redis Pub/Sub does not keep messages.
skipMiddlewareswould let a user who was banned during the gap come back without re-checking, so be careful.
Scaling to many servers
One Node process holds a limited number of connections (limited by memory, CPU and file descriptors). Soon you run several servers behind a load balancer. Two new problems appear.
Problem 1: the request must reach the right server (sticky sessions). Socket.IO's HTTP long-polling sends many separate HTTP requests for one session. If the balancer sends request 2 to another server, that server does not know the session. I tested it: a polling request with a sid created on server A, sent to server B, got 400 {"code":1,"message":"Session ID unknown"}.
Sticky sessions (also called session affinity) means the load balancer sends all requests from one client to the same server, using a cookie or the client IP. nginx can do it with hash $remote_addr consistent; or ip_hash, HAProxy and others with a cookie. The Socket.IO docs list three ways out:
- Sticky sessions.
- WebSocket only (
transports: ['websocket']on the client): then there is one long connection, and no session to share. I ran two servers with websocket-only clients and no sticky rule, and it worked. - Client-side load balancing (the client picks the server).
Problem 2: a message must reach sockets on other servers (the adapter). io.to('room1').emit(...) only knows the sockets of its own process. An adapter is the part of Socket.IO that decides how a broadcast travels between servers. The Redis adapter (@socket.io/redis-adapter) publishes each broadcast to Redis Pub/Sub and every server forwards it to its own matching sockets.
import { Redis } from 'ioredis';import { createAdapter } from '@socket.io/redis-adapter';const pub = new Redis(process.env.REDIS_URL);const sub = pub.duplicate(); // a subscriber needs its own connectionconst io = new Server(httpServer, { adapter: createAdapter(pub, sub) });With two Socket.IO servers on one Redis, I confirmed: io.to('room1').emit on server A reached a client connected to server B; fetchSockets() on A counted 2 sockets while only 1 was local; serverSideEmit reached server B; and a broadcast with emitWithAck collected an answer from each client on both servers.
Points to remember:
- Redis Pub/Sub does not store anything. If a server is disconnected from Redis for a moment, it misses broadcasts. The adapter gives fan-out, not delivery guarantees.
- The docs recommend the sharded adapter (
createShardedAdapter) for new work on Redis 7+, which scales better in Redis Cluster. - Redis is trusted. Adapter messages are not signed or encrypted. Do not expose that Redis to the internet or to untrusted apps, and use auth, TLS and private networking.
- The adapter does not replace sticky sessions when polling is allowed. They solve two different problems: where do requests go and how do broadcasts cross servers.
A good senior answer: "Sticky sessions for the polling transport, or WebSocket-only. The Redis adapter for cross-server broadcast. The database for anything that must not be lost."
Authentication and authorization on sockets
Authentication means proving who the user is. Authorization means checking what that user may do. For sockets there is a special problem: you authenticate once, at connect time, and the connection then lives for hours.
Where to put the credential (browser WebSocket cannot set headers):
| Method | How | Trade-off |
|---|---|---|
| Cookie | Browser sends the session cookie in the handshake automatically | Simple; works only for the same site. Must check Origin (see below) |
Token in the URL (?token=) | Put a JWT in the query string | Easy, but URLs end up in logs and proxies. Use only a short-lived ticket |
| Ticket | POST /ws-ticket (normal authenticated call) returns a random one-use token valid for ~30 s; the socket URL carries that ticket | Safe against log leaks; one extra request |
| First message | Connect, then send {type:"auth", token}; close if it does not arrive in a few seconds | Header-free, but unauthenticated sockets exist briefly |
Socket.IO auth | io({ auth: { token } }), read in middleware as socket.handshake.auth | Built in, not in the URL |
Rules I follow:
- Check before upgrading. In
ws, do it in the HTTP server'supgradeevent and answer401(I tested this). Thewsdocs discourageverifyClientand point to theupgradeevent instead. In Socket.IO, use middleware (io.use), which runs once per connection. - Check
Originwhen you use cookies. WebSocket handshakes are not blocked by the browser's same-origin rule, so a malicious page could open a socket to your server using the victim's cookie. This attack is called cross-site WebSocket hijacking. Compare theOriginheader with a list of allowed sites. - Authorize each action, not only the connection. When a client sends
join-room: "project:9", check that this user may see project 9. Never trust a room name from the client. - Tokens expire, sockets do not. If the token lasts 15 minutes but the socket lasts 8 hours, a removed user stays connected. Either re-auth on a timer (client sends a fresh token over the socket), or close sockets when permissions change (for example
io.in('user:42').disconnectSockets()). - Rate-limit messages per connection. A socket is an open door; one client can send thousands of messages per second.
- Validate every payload (for example with Zod). Data from a socket is untrusted input, like a request body.
Proxies, timeouts and the network in the middle
Most "my WebSocket works locally but not in production" bugs live in the layers between browser and app.
- The proxy must pass the upgrade. nginx needs
proxy_http_version 1.1;and theUpgradeandConnection "upgrade"headers. Without them, the handshake fails. - Idle timeout. Proxies and load balancers close connections that stay silent. nginx's
proxy_read_timeoutis 60 seconds by default. The Socket.IO docs warn that it must be bigger thanpingInterval + pingTimeout(45 s by default), otherwise nginx kills the connection and the client seestransport close. Cloud load balancers have their own idle timeouts. The cure for all of them is the same: send a heartbeat more often than the shortest timeout in the path. - Buffering. A proxy that collects the response before forwarding it destroys SSE. Use
X-Accel-Buffering: nofor nginx,Cache-Control: no-cache, no-transform, and check your CDN or load balancer. - Compression by a proxy can also buffer a stream. Test with
curl -Nto see if chunks arrive one by one. - CDNs and caches must never cache the stream.
- Restarts and deploys. Every deploy cuts all connections at once, and all clients reconnect at once. This is why jitter matters, and why servers should drain slowly: stop accepting new connections, send a "reconnect soon" message, close in small groups.
Debug order I use: (1) does curl -N show events? (2) browser devtools Network tab: the WS row, its frames and its close code; (3) the proxy log; (4) the app log.
Common WebSocket close codes: 1000 normal; 1001 going away (tab closed or server restarting); 1006 abnormal (no close frame, usually a network or proxy cut); 1008 policy violation; 1009 message too big; 1011 server error.
Serverless limits
Serverless means your code runs in short-lived function instances that the platform starts and stops for you. Each request has a maximum duration, and instances are shut down when idle.
- A WebSocket needs a process that stays alive and keeps state in memory. The Next.js docs warn that on hosts that deploy Route Handlers as lambda-style functions, "WebSockets won't work", because the connection closes on timeout or after the response is generated, and that long-running handlers may be terminated.
- SSE fits better because it is just a long HTTP response, but it is still cut at the platform's maximum duration. You can raise it with the
maxDurationroute setting where the platform supports it, but the platform decides the limit. A good SSE client reconnects withLast-Event-ID, so a cut is only a short gap. - Each open stream also holds a function instance, which can cost money while it waits.
- Platforms change these rules often. Check your host's current documentation for connection support and duration limits before you design around either.
Common solutions: (1) run the realtime server as a separate long-lived service (a small Node server on a container platform) and keep the rest on serverless; (2) use a managed realtime service (a hosted pub/sub that holds the connections and calls your code by HTTP); (3) use SSE with short cycles and reconnect; (4) poll. The Next.js docs also say that a custom server (next start replaced by your own server.js) can host the WebSocket server in the same process, but the Next.js docs say to use a custom server only when the built-in server cannot meet your needs, and it does not suit serverless hosts.
Presence ("who is online")
Presence is the list of users who are online right now. Disconnects are noisy (a flaky network, a page refresh), so do not trust connect and disconnect events alone.
My approach, tested with Redis:
- Each live socket sends a heartbeat. The server records
(user, socket)with the time last seen in a Redis sorted set (ZADD presence <now> user:socket). - A user is online if any of their sockets has a score newer than the timeout (
ZRANGEBYSCORE). This handles several tabs correctly: Alice was still online after tab 1 expired because tab 2 had beaten later. - A cleanup job removes old scores (
ZREMRANGEBYSCORE). Dead servers cannot leave "ghost users" because their entries simply expire. - Show "offline" only after the timeout, not at the first disconnect, so a page refresh does not flicker.
- Broadcast presence changes (user came online or went offline), not the full list every time. For big rooms, send a count.
Common mistakes
- No error listener on
wssockets: one bad frame kills the process. - No heartbeat, so ghost connections fill memory and a proxy closes idle ones.
- Reconnect without jitter, so a deploy turns into an attack on yourself.
- Reconnect without a catch-up from the database, so users silently miss messages.
- Sending first, then storing. Store the message, then broadcast.
- Trusting the room name or user id sent by the client.
- Sending huge messages or unbounded queues to slow clients.
- Sticky sessions forgotten with the polling transport, or "Redis adapter will fix it".
- Buffering proxy between you and the browser on SSE.
- Putting a long-lived token in the URL where logs can read it.
Interview questions
(Must know) Compare polling, long polling, SSE and WebSocket. How do you pick?
Polling asks on a timer, so it wastes requests and is always a little late. Long polling holds the request until there is news, so it is faster but still makes one request per update. SSE keeps one HTTP response open and the server writes events into it; it is one-way and reconnects by itself. WebSocket upgrades the connection to a two-way message channel. I pick by direction and by environment. If only the server pushes (notifications, progress, AI streaming), I take SSE because it is plain HTTP and reconnects with Last-Event-ID. If the client also sends often or I need binary (chat, games, editors), I take WebSocket. If updates are rare, polling is fine. And I check what the host allows before I decide.
(Must know) What happens during a WebSocket handshake?
The browser sends a normal HTTP GET with Upgrade: websocket, Connection: Upgrade, a random Sec-WebSocket-Key and the version 13. The server answers 101 Switching Protocols with Sec-WebSocket-Accept, which is a hash of that key. After that, the same TCP connection carries WebSocket frames instead of HTTP. The key and accept value only prove the server understands the protocol; they are not authentication. Cookies and headers travel once, in this first request, so that is the place to authenticate.
(Must know) How do you authenticate a WebSocket, given the browser cannot set an Authorization header?
I have these options: the session cookie, which the browser sends for me; a short-lived one-time ticket in the URL that the client got from a normal authenticated request; a token in the first message, with a short deadline; or, with Socket.IO, the auth object. I avoid a long-lived JWT in the query string because URLs are logged. I check before upgrading (the upgrade event in ws, middleware in Socket.IO) and answer 401. With cookies I also check the Origin header to stop cross-site hijacking. After that I authorize each action, because the connection can live for hours.
(Must know) Why do you need heartbeats?
A connection can die without either side knowing, for example when a phone loses signal or a router drops. TCP may take minutes to notice. Without a heartbeat, the server keeps ghost sockets, and proxies may close the line for being idle. So the server sends a ping on a timer, marks the socket as waiting, and terminate()s any socket that did not answer before the next round. I tested it with a client that ignores pings: it was dropped after two rounds. The interval must be shorter than the shortest idle timeout in the path, such as nginx's 60 seconds.
(Must know) What is the difference between ws and Socket.IO?
ws is a thin, fast implementation of the plain WebSocket protocol. You get messages and ping/pong, and nothing else; you write reconnect, rooms and acks yourself. Socket.IO is a full library with its own protocol on top of Engine.IO. It adds automatic reconnect, rooms, namespaces, acknowledgements, buffering, middleware and multi-server support, and it can fall back to HTTP long polling. The cost is that a Socket.IO server cannot talk to a plain WebSocket client, a bit more overhead, and lock-in to its client. I pick ws for a simple standard endpoint or when clients are not mine, and Socket.IO when I need rooms and acks quickly and I own both ends.
(Must know) Explain rooms, namespaces and acknowledgements in Socket.IO.
A room is a named group of sockets on the server, used to send to a subset; sockets join and leave it, and leave automatically on disconnect. A namespace is a separate channel on the same connection with its own events and middleware, like /admin; it separates features. An ack is a callback on one event that turns it into a request and reply. I use a room per user (user:42) so one emit reaches all their tabs, a room per object (project:9) for updates, and I always use socket.timeout(ms).emitWithAck() so a lost reply does not wait forever. A note: socket.to(room) skips the sender, io.to(room) does not.
(Senior signal) Does Socket.IO guarantee that messages arrive?
No. It guarantees order, but delivery is at most once by default. If the connection breaks in flight, the event may be lost. The client buffers what it sends while offline, but the server does not buffer for a disconnected client. For client to server, I can use retries and ackTimeout. For server to client, I give each event an id, store it, and when the client reconnects it sends its last id in auth; the server replays what is newer. Connection state recovery helps for short drops but it is not guaranteed, so I still need a resync path for the case recovered === false. The database stays the source of truth.
(Senior signal) You run Socket.IO on four servers. What breaks and how do you fix it?
Two things break. First, with HTTP long polling, requests of one session must hit the same server, or you get 400 Session ID unknown (I reproduced this). I fix it with sticky sessions at the load balancer, or I make clients WebSocket-only. Second, io.to(room).emit() only reaches sockets on the current process. I add an adapter; the Redis adapter publishes broadcasts through Pub/Sub so every server forwards to its own sockets. With it, an emit on server A reached a client on server B in my test. But these are two different fixes. Also the adapter is fire and forget: no delivery guarantee and no state recovery with the classic Redis adapter.
(Senior signal) What does the Redis adapter not give you?
It does not store messages, so a server that loses its Redis connection misses broadcasts. It does not replace sticky sessions. It does not support connection state recovery in its Pub/Sub form (the Redis Streams adapter does). It does not secure anything: messages are unsigned, so Redis must sit in a private network with auth and TLS, and it should not be shared with untrusted apps. And it makes Redis a dependency and a possible bottleneck, so for new Redis 7 setups I would look at the sharded adapter.
(Trap) "The WebSocket works locally but drops every 60 seconds in production." What do you check?
That exact number points to an idle timeout. nginx's proxy_read_timeout is 60 seconds by default, and cloud load balancers have their own. If no data flows for that long, the proxy closes the line, and the client sees close code 1006 or Socket.IO's transport close. I check the proxy config and send a heartbeat more often than the timeout. For Socket.IO the proxy timeout must be larger than pingInterval + pingTimeout (45 s by default). I also check that the proxy passes Upgrade and Connection headers with HTTP/1.1.
(Must know) Show how you would stream SSE from a Next.js Route Handler.
I export a GET from app/api/.../route.ts, build a ReadableStream, and return new Response(stream, { headers }) with Content-Type: text/event-stream, Cache-Control: no-cache, no-transform and X-Accel-Buffering: no. Inside start, I subscribe to my event source, replay events newer than the Last-Event-ID header, and start a comment heartbeat. I clean up on both cancel() and request.signal abort. I write events as id:, event:, data: and a blank line. I ran it with a real EventSource and it recovered missed events after a forced disconnect. In production I also keep events in a store, because an in-memory list is lost on restart and not shared across instances.
(Senior signal) What are the traps of SSE?
Proxy buffering, which makes events arrive in lumps or not at all, fixed with X-Accel-Buffering: no and checking CDNs. The 6-connections-per-domain limit on HTTP/1.1, which hurts when users open many tabs (HTTP/2 fixes it). No custom headers and only GET with EventSource, so authentication is by cookie, or I switch to fetch streaming. Leaks when I forget to clean up on disconnect. Serverless duration limits that cut the stream, which is fine only if the client reconnects with Last-Event-ID. And text only: binary has to be encoded.
(Senior signal) What is backpressure and how do you handle it for WebSockets?
Backpressure is how a slow receiver slows the sender. ws.send() does not block; it queues in memory. A slow client makes that queue grow, and with thousands of clients you run out of memory. I watch ws.bufferedAmount. In my test, a client that stopped reading made the buffer reach my 1 MB limit, and the code skipped the next 263 updates. For "latest value wins" data I skip or replace; for data where every message matters I keep a bounded queue and close the connection if it overflows, so the client reconnects and catches up from the database. I never keep an unbounded queue per connection.
(Trap) What is wrong with new WebSocketServer(...) and no ws.on('error')?
An error event with no listener makes Node throw, and the process exits. In my test, a client sent a message above maxPayload; ws closed with code 1009 and also emitted error on the server socket, which crashed the server until I added a listener. Any bad frame, invalid UTF-8 or oversized message can trigger it. So I attach an error listener to every socket. I also set maxPayload (the default is 100 MiB), validate messages, and keep compression off unless I measured a need.
(Senior signal) How do you prevent cross-site WebSocket hijacking?
The browser's same-origin rule does not stop a page on another site from opening a WebSocket to my server, and the browser would send my cookies. So if I authenticate with a cookie, I check the Origin header in the handshake against an allowed list and refuse others. Better, I use a one-time ticket or token that a foreign page cannot get. I also still authorize every action, and I use wss:// so traffic is encrypted.
(Senior signal) Design reconnect logic for a chat client. What must it do?
First, exponential backoff with jitter, capped, reset after a success, so a deploy does not cause a stampede. Second, a catch-up step: on reconnect the client sends the id of the last message it saw, and the server replays newer ones from the database; the client ignores ids it has already shown, because overlap is possible. Third, a heartbeat or timeout so the client notices a dead line. Fourth, show a "reconnecting" state and queue outgoing messages with client-generated ids, so a retry does not create duplicates (idempotency). Fifth, refresh the auth token before reconnecting. Fallback: if sockets keep failing, poll.
(Must know) Can you run WebSockets on serverless like Vercel-style functions? What do you do instead?
I would not rely on it. Function instances have a maximum duration and are stopped when the response ends; the Next.js docs say plainly that on lambda-style hosts WebSockets won't work and long handlers may be terminated. Platforms change, so I check the host's current documentation. My usual design: keep the app on serverless, run the realtime part as a small long-lived Node service (or use a managed realtime service), and let the browser connect to that. If the need is one-way, SSE from a function with a short duration and automatic reconnect with Last-Event-ID may be enough. Polling is the final fallback.
(Senior signal) How do you build "who is online" correctly?
I do not trust connect and disconnect events alone. Each socket sends a heartbeat, and I store user:socket with a last-seen time in a Redis sorted set. A user is online if any of their sockets has been seen within the timeout. This handles many tabs and dead servers, because stale entries just expire. I wait for the timeout before showing "offline", to avoid flicker on page refresh, and I broadcast changes, not full lists. In a test with a 60-second timeout, a user stayed online as long as one of two tabs still sent beats, and a user with no beats went offline after the timeout.
(Trap) A teammate says: "We use Redis pub/sub, so no message is ever lost." Respond.
Redis Pub/Sub is fire and forget. It sends to the subscribers connected right now and keeps nothing. A server that is restarting, or has a network blip, misses those messages for good, and so does a client that is offline. So Pub/Sub gives fast fan-out, not reliability. For reliability I write the message to a database or a log first (Redis Streams, Kafka, a table with ids), broadcast second, and let clients catch up using the last id they saw. That is "at least once" with deduplication on the client.
(Senior signal) Why would you still choose polling in some cases?
When the data changes slowly and exactness is not critical, for example a job status every few seconds. Polling works everywhere (every proxy, every host, serverless, caches), needs no special infrastructure, and it is easy to test and to retry. A few thousand clients polling every 5 seconds is cheap with a CDN or a cheap endpoint, while sockets add a stateful tier that I must scale, monitor and reconnect. I use backoff, pause polling when the tab is hidden, and ETag or If-None-Match so unchanged answers are tiny. If the update rate grows or latency matters, I move to SSE.
Key takeaways
- Pick by direction: SSE for server-to-browser, WebSocket for two-way, polling for slow data.
- Always: heartbeat, error listener, message size limit, backpressure check, reconnect with backoff and jitter.
- Delivery is your job. Store events with ids, broadcast after storing, and let clients catch up (
Last-Event-ID, an offset inauth). - Socket.IO is its own protocol: rooms, namespaces, acks, reconnect. At most once by default.
- Many servers need sticky sessions or WebSocket-only and an adapter (Redis Pub/Sub, no storage).
- Authenticate before upgrading, check
Origin, authorize every action, and remember tokens expire but sockets do not. - Between browser and app, the proxy decides your timeouts and buffering. Serverless hosts limit duration, so check yours.
Published by Jainik · Last updated · How these lessons are made
Keep learning
Related series