The 60 WebSockets questions interviewers actually ask, with direct answers, runnable code, and what the interviewer is listening for. Grouped for freshers, intermediate, and experienced rounds.
60 questions with answersKey Takeaways
WebSocket is a communication protocol standardized as RFC 6455 that provides a full-duplex channel over a single TCP connection. A client opens it with an HTTP request carrying an Upgrade: websocket header; if the server agrees, it replies with a 101 Switching Protocols status and the same socket is reused for the WebSocket protocol from then on. After that handshake, either side can push messages whenever it wants, which is what plain HTTP request/response can't do without polling. According to MDN Web Docs, the WebSocket API makes it possible to open a two-way interactive session between the browser and a server, so you can send messages and receive event-driven responses without polling. In interviews, WebSocket questions probe the handshake, the ws:// and wss:// schemes, message framing, ping/pong keepalive, reconnection, and how you'd scale connections across servers, not memorized API calls. This page collects the 60 questions that come up most, each with a direct answer and runnable code. Increasingly the first round is a live AI coding interview, so pair this bank with our AI interview preparation guides for the format side.
Watch: A Beginner's Guide to WebSockets
Video: A Beginner's Guide to WebSockets (freeCodeCamp.org, YouTube)
Test yourself and earn a certificate
10 quick questions. Score 70%+ to download your WebSockets certificate.
The fundamentals every entry-level round checks. If any answer here surprises you, that's your study list.
A WebSocket is a protocol that opens a single, long-lived, full-duplex connection between a client and a server over one TCP socket. Once it's open, both sides can send messages at any time without a fresh request each time.
It's built for real-time features: chat, live dashboards, notifications, multiplayer games. The connection starts as an HTTP request that the server upgrades, then stays open for two-way messaging.
Key point: Lead with 'full-duplex over one persistent connection' and one real use case. Interviewers open with this to hear whether you understand the shape of the protocol.
Watch a deeper explanation
Video: WebSockets in 100 Seconds & Beyond with Socket.io (Fireship, YouTube)
HTTP is request/response and short-lived: the client asks, the server answers, and the connection is typically done. WebSocket is a persistent, full-duplex connection where the server can push messages to the client without being asked.
HTTP is stateless and cacheable, which suits fetching pages and data. WebSocket keeps state (an open connection per client) and suits continuous two-way messaging where new data arrives on the server's schedule.
| HTTP | WebSocket | |
|---|---|---|
| Connection | Short-lived per request | One long-lived connection |
| Direction | Client requests, server responds | Full-duplex, both push |
| State | Stateless | Stateful (connection stays open) |
| Best for | Pages, REST APIs | Real-time, live updates |
Key point: The follow-up is almost always 'so when would you still use HTTP?'. Have caching and stateless simplicity ready.
Watch a deeper explanation
Video: What are WebSockets | How is it different from HTTP? (Tech Primers, YouTube)
ws:// is a plaintext WebSocket connection; wss:// is a WebSocket over TLS, the encrypted version. wss is to ws what https is to http.
In production you use wss:// so messages can't be read or tampered with in transit, and because pages served over https are only allowed to open secure WebSocket connections. The default ports are 80 for ws and 443 for wss.
// plaintext, fine for local dev only
const dev = new WebSocket("ws://localhost:8080");
// encrypted, required in production
const prod = new WebSocket("wss://api.example.com/socket");Key point: Saying 'an https page can't open a ws:// connection, the browser blocks it as mixed content' shows you've hit this in real work.
Full-duplex means both the client and the server can send messages independently, at the same time, over the one connection. Neither side has to wait for the other to finish or to be asked.
This is the core difference from request/response HTTP, which is effectively half-duplex from the app's view: the client asks, then the server answers. With WebSocket a chat message from you and a message from the server can be in flight together.
It starts as a normal HTTP GET request with headers Upgrade: websocket and Connection: Upgrade, plus a random Sec-WebSocket-Key. If the server agrees, it responds with status 101 Switching Protocols and a Sec-WebSocket-Accept header derived from that key.
After the 101 response, the same TCP connection stops speaking HTTP and starts carrying WebSocket frames. So one connection is reused: HTTP for the handshake, then the WebSocket protocol for everything after.
GET /chat HTTP/1.1
Host: example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
-- server replies --
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=Key point: Naming the 101 status and 'the handshake is HTTP, then it upgrades' is the exact detail the key signal is here.
Watch a deeper explanation
Video: WebSockets Crash Course - Handshake, Use-cases, Pros & Cons and more (Hussein Nasser, YouTube)
You create a WebSocket object with the server URL, then attach event handlers. The four events are open (connected), message (data arrived), close (connection ended), and error.
send() pushes data to the server, and close() shuts the connection down cleanly. The API is small; most of the real work is handling the lifecycle events well.
const socket = new WebSocket("wss://example.com/chat");
socket.onopen = () => socket.send("hello server");
socket.onmessage = (event) => console.log("got:", event.data);
socket.onclose = (event) => console.log("closed", event.code);
socket.onerror = (err) => console.error("error", err);open fires once when the connection is established. message fires every time data arrives, with the payload in event.data. close fires when the connection ends, carrying a code and reason. error fires on a failure, usually just before a close.
A resilient client wires all four: open to start sending, message to handle data, close to trigger reconnection logic, and error to log. Ignoring close and error is the most common beginner mistake.
You send with socket.send(data), where data is a string, Blob, or ArrayBuffer. You receive by handling the message event and reading event.data. There's no request/response pairing built in; a message is just a message.
Because there's no built-in shape, apps usually agree on a format. JSON is the common choice: stringify on the way out, parse on the way in, and put a type field on every message so the receiver knows what it got.
// sending
socket.send(JSON.stringify({ type: "chat", text: "hi" }));
// receiving
socket.onmessage = (event) => {
const msg = JSON.parse(event.data);
if (msg.type === "chat") render(msg.text);
};Anything where updates need to reach the user the moment they happen, without the user refreshing or the page polling. Chat and messaging, live sports scores and stock tickers, collaborative editors like shared docs, multiplayer games, and live dashboards or notifications.
The common thread is server-driven, low-latency updates. If the server is the one that knows when something changed, a WebSocket pushes it out instantly instead of the client asking again and again.
Call socket.close(), optionally with a close code and a short reason string. The other side gets a close event with that code, and the TCP connection is torn down cleanly.
Code 1000 means normal closure. You can pass application-defined codes in the 4000-4999 range for your own reasons. Always closing cleanly matters because it lets the peer distinguish an intended shutdown from a dropped connection.
// clean, normal close
socket.close(1000, "user left the page");
// custom app code
socket.close(4001, "session expired");readyState tells you what phase the connection is in. It's one of four numeric constants: CONNECTING (0), OPEN (1), CLOSING (2), and CLOSED (3).
You check it before sending, because calling send() on a socket that isn't OPEN throws. Guarding with if (socket.readyState === WebSocket.OPEN) is a habit that prevents a class of runtime errors.
function safeSend(socket, data) {
if (socket.readyState === WebSocket.OPEN) {
socket.send(data);
} else {
console.warn("socket not open, dropping message");
}
}| Value | Constant | Meaning |
|---|---|---|
| 0 | CONNECTING | Handshake in progress |
| 1 | OPEN | Ready to send and receive |
| 2 | CLOSING | Close handshake started |
| 3 | CLOSED | Connection is done |
Both. WebSocket frames are typed as text or binary at the protocol level. In the browser you send a string for text, or a Blob or ArrayBuffer for binary; incoming binary arrives as a Blob by default, switchable to ArrayBuffer via binaryType.
Text is usually JSON for app messages. Binary suits media, file chunks, or compact formats like Protocol Buffers where you want fewer bytes on the wire.
socket.binaryType = "arraybuffer";
socket.onmessage = (event) => {
if (typeof event.data === "string") {
handleText(event.data);
} else {
handleBinary(new Uint8Array(event.data));
}
};TCP. A WebSocket runs on top of a single TCP connection, so it inherits TCP's ordered, reliable delivery: messages arrive in order and aren't silently lost.
That's why WebSocket isn't the right tool for real-time audio or video, where dropping a late packet beats waiting for it; that's WebRTC's job over UDP. WebSocket trades some latency for reliability and ordering.
WebSocket is the raw protocol. Socket.IO is a library built on top of it that adds features you'd otherwise write yourself: automatic reconnection, rooms and namespaces, acknowledgements, and a fallback to HTTP long polling when WebSocket is unavailable.
The trade-off: Socket.IO isn't plain WebSocket on the wire, so a Socket.IO client needs a Socket.IO server. For simple needs, raw WebSocket is lighter; for chat-style apps that want rooms and resilience out of the box, the library saves work.
| Raw WebSocket | Socket.IO | |
|---|---|---|
| What it is | Browser API / protocol | Library on top |
| Reconnection | You build it | Built in |
| Rooms / broadcast | You build it | Built in |
| Fallback transport | None | HTTP long polling |
| Interoperable | Any WS client/server | Needs Socket.IO both ends |
Key point: Interviewers love 'what does Socket.IO give you over plain WebSocket?'. Reconnection, rooms, and fallback is the crisp three-part answer.
Polling means the client asks the server for updates on a timer, say every few seconds. Most of those requests come back with nothing new, wasting bandwidth and adding latency equal to the poll interval. WebSocket pushes the update the instant it exists.
Long polling is a middle ground where the server holds the request open until it has data, but it still pays request setup overhead and can't do efficient two-way messaging. WebSocket's one persistent connection beats both for genuinely real-time, bidirectional needs.
WebSocket uses the standard web ports: 80 for ws and 443 for wss, the same ports as HTTP and HTTPS. Because the connection begins as an ordinary HTTP request, it passes through most firewalls and proxies that already allow web traffic.
That reuse of port 443 is a practical reason WebSocket won out over older push techniques: you don't need to open a new port, and wss traffic looks like normal HTTPS to the network.
Yes. Every connected client holds one open WebSocket to the server, and the server tracks all of them. To broadcast, the server iterates its set of open connections and sends to each.
This is why WebSocket is stateful and why connection count is the thing you scale. A REST server can handle a request and forget it; a WebSocket server holds thousands of live sockets in memory at once.
// Node.js with the 'ws' library
import { WebSocketServer } from "ws";
const wss = new WebSocketServer({ port: 8080 });
wss.on("connection", (socket) => {
socket.on("message", (data) => {
// broadcast to every connected client
for (const client of wss.clients) {
if (client.readyState === 1) client.send(data);
}
});
});Yes, within a single connection. Because WebSocket runs on one TCP connection, messages are delivered in the order they were sent and none are silently dropped while the connection is open.
What TCP does not guarantee is that a message reaches the application if the connection drops mid-flight. So ordering is solved for you, but delivery across a reconnection is something your app has to handle, often with message IDs or acknowledgements.
WebSockets aren't restricted by the browser's same-origin policy the way fetch is; a page can open a WebSocket to a different origin without a CORS preflight. The handshake does send an Origin header, though.
That's the catch: because the browser won't block cross-origin WebSocket for you, the server must check the Origin header itself and reject connections from origins it doesn't trust. Skipping that check is the cross-site WebSocket hijacking risk.
Key point: Mentioning that the server has to validate Origin because the browser won't is exactly the security awareness this question screens for.
An echo server accepts a connection, then sends back whatever message it receives. It's the classic first example because it exercises the whole lifecycle: accept, receive, send, in a few lines.
In Node with the ws library you listen for a connection, then for each message on that socket you send the same data back. That loop is the seed of every real WebSocket app; you replace 'echo it back' with actual routing logic.
import { WebSocketServer } from "ws";
const wss = new WebSocketServer({ port: 8080 });
wss.on("connection", (socket) => {
socket.on("message", (data) => {
socket.send(`echo: ${data}`);
});
});Key point: Being able to type this from memory is the entry bar. The follow-up is 'now broadcast to all clients instead of echoing', so know wss.clients too.
For candidates with working experience: protocol mechanics, resilience patterns, and the questions that separate users from understanders.
After the handshake, data on the connection travels in frames, not raw bytes. Each frame has a small header with an opcode saying what it is, a length, and (from the client) a masking key, followed by the payload.
Opcodes split into data frames (text 0x1, binary 0x2, continuation 0x0) and control frames (close 0x8, ping 0x9, pong 0xA). Control frames handle the connection itself; data frames carry your messages. A big message can be split across several frames using continuation.
| Opcode | Frame type | Purpose |
|---|---|---|
| 0x0 | Continuation | Next part of a fragmented message |
| 0x1 | Text | UTF-8 text payload |
| 0x2 | Binary | Binary payload |
| 0x8 | Close | Start the closing handshake |
| 0x9 / 0xA | Ping / Pong | Keepalive control frames |
Ping and pong are control frames used for keepalive. One side sends a ping; the peer must reply with a pong carrying the same payload. This proves the connection is still working end to end and stops idle proxies from closing it.
The server usually pings on a timer. If no pong comes back within a timeout, it treats the connection as dead and cleans it up, which is how you detect half-open connections that TCP alone wouldn't reveal.
// Node 'ws' server: drop clients that stop responding
setInterval(() => {
for (const client of wss.clients) {
if (client.isAlive === false) return client.terminate();
client.isAlive = false;
client.ping();
}
}, 30000);
wss.on("connection", (ws) => {
ws.isAlive = true;
ws.on("pong", () => { ws.isAlive = true; });
});Key point: The insight to state: TCP can go half-open silently, so app-level ping/pong is how you actually know a client is still there.
Connections drop: laptops sleep, networks flap, servers restart. A resilient client listens for the close event and reconnects, but not instantly and not forever at the same interval. You use exponential backoff with jitter so a fleet of clients doesn't reconnect in a thundering herd.
On reconnect, you also re-establish state: re-authenticate, re-subscribe to channels, and often request anything missed while offline using a last-seen message ID. Raw WebSocket gives you none of this, which is one reason libraries exist.
function connect(url, attempt = 0) {
const socket = new WebSocket(url);
socket.onopen = () => { attempt = 0; };
socket.onclose = () => {
const base = Math.min(1000 * 2 ** attempt, 30000);
const delay = base + Math.random() * 1000; // jitter
setTimeout(() => connect(url, attempt + 1), delay);
};
return socket;
}Key point: Say 'exponential backoff with jitter' explicitly. Reconnecting on a fixed timer is the answer that reveals no production experience.
TCP can be half-open: one side is gone but the other's kernel hasn't noticed, so no close event fires. The only reliable fix is an application-level heartbeat. The server pings on an interval and expects a pong; missing pongs mean the client is really gone.
You pair this with a timeout: if two or three heartbeats go unanswered, terminate the socket and free its resources. Without a heartbeat, dead connections pile up and leak memory on a long-running server.
Back pressure happens when you send data faster than the client (or the network) can accept it. The unsent bytes queue up in a send buffer, and if you ignore it, memory grows until the process is in trouble.
In the browser you watch socket.bufferedAmount and pause sending when it's high. On the server you check the write return value or the buffer size and slow the producer, drop lower-priority messages, or disconnect a client that can't keep up. Ignoring back pressure is a classic cause of a server falling over under load.
function sendWithBackpressure(socket, data) {
const LIMIT = 1_000_000; // 1 MB
if (socket.bufferedAmount > LIMIT) {
return false; // skip or queue, don't pile on
}
socket.send(data);
return true;
}Key point: Naming bufferedAmount on the client and 'slow the producer or drop messages' on the server is what separates depth from a textbook definition.
The WebSocket API doesn't let you set custom headers from the browser, so you can't just send an Authorization header. Common patterns: pass a short-lived token as a query parameter on the connect URL, rely on an existing session cookie sent during the handshake, or authenticate with the first message after the socket opens.
Whatever you pick, validate on the server during or right after the handshake and close the connection if it fails. Query-string tokens should be short-lived because URLs land in logs, and cookie-based auth needs CSRF-style protection via the Origin check.
On a single server you keep a collection of open connections and loop over it, sending to each socket that's in the OPEN state. Grouping clients into rooms or channels lets you broadcast to a subset instead of everyone.
The catch appears when you scale past one server: clients are spread across processes, so a plain in-memory loop only reaches the clients on that one server. That's when you move broadcasts through a shared pub/sub layer so every server delivers to its own clients.
function broadcast(clients, message, exclude) {
const data = JSON.stringify(message);
for (const client of clients) {
if (client !== exclude && client.readyState === 1) {
client.send(data);
}
}
}A room is just a named group of connections. You keep a map from room name to the set of sockets in it; join adds a socket, leave removes it, and a broadcast to a room iterates only that set. Socket.IO ships this, but the idea is a few lines to build.
When you scale to many servers, room membership is spread across processes, so a message published to a room has to fan out through pub/sub to reach members on other servers. The room abstraction stays; the delivery gains a network hop.
const rooms = new Map(); // roomName -> Set<socket>
function join(room, socket) {
if (!rooms.has(room)) rooms.set(room, new Set());
rooms.get(room).add(socket);
}
function toRoom(room, data) {
for (const s of rooms.get(room) ?? []) {
if (s.readyState === 1) s.send(data);
}
}The protocol carries opaque payloads, so you define the shape. Almost every app puts a type or event field on each message plus a data payload, and serializes as JSON. That lets the receiver route each message to the right handler.
For high-volume or bandwidth-sensitive systems, a binary format like Protocol Buffers or MessagePack shrinks the payload and speeds parsing. Whatever the format, versioning the message schema early saves pain when the protocol evolves.
// a simple typed envelope
socket.send(JSON.stringify({
type: "chat.message",
data: { room: "general", text: "hi" },
id: crypto.randomUUID()
}));Choose SSE when you only need server-to-client updates: live feeds, notifications, stock tickers, progress bars. SSE runs over a plain HTTP response, reconnects automatically, and is simpler to deploy because it's just HTTP.
Choose WebSocket when the client also needs to send frequently, like chat or collaborative editing, where the two-way channel matters. SSE is one-directional and text-only, so anything that needs client-to-server messaging or binary frames points to WebSocket.
| SSE | WebSocket | |
|---|---|---|
| Direction | Server to client only | Full-duplex |
| Protocol | Plain HTTP | Upgraded protocol |
| Auto-reconnect | Built in | You build it |
| Binary | Text only | Text and binary |
| Best for | Feeds, notifications | Chat, collaboration, games |
Watch a deeper explanation
Video: How Web Sockets work | Deep Dive (ByteMonk, YouTube)
Browsers don't apply the strict per-host limit to WebSockets that they apply to HTTP/1.1 requests; the practical cap is much higher, in the hundreds per host. But you rarely want many: one connection can multiplex all your app's channels using your own message types.
The real limit is on the server: each connection holds memory and a file descriptor, so a single machine tops out in the tens or hundreds of thousands depending on tuning. That server-side ceiling, not the browser, drives your architecture.
The proxy or load balancer has to understand the HTTP Upgrade handshake and then stop treating the connection as a normal request, keeping the TCP connection open and passing frames through. Older or misconfigured proxies buffer or close it, which breaks WebSocket.
You also raise idle timeouts, because a mostly-quiet WebSocket looks idle and a short timeout will kill it (this is partly why heartbeats exist). On layer-7 load balancers you enable WebSocket or Upgrade support explicitly.
Key point: 'raise the idle timeout or the LB kills quiet connections' matters.
A WebSocket is one long-lived connection to one server, so once it's established, all its frames go to that server. Sticky sessions (session affinity) make sure the initial handshake and the ongoing connection land on the same backend, which matters if per-connection state lives in that server's memory.
The alternative is to keep no per-connection state on the server and route all shared state through an external store or pub/sub, which removes the need for stickiness but adds a network hop. Which you pick depends on how much you scale out.
Yes, through the permessage-deflate extension, negotiated during the handshake. It compresses each message with DEFLATE, which cuts bandwidth for text-heavy payloads like JSON.
It isn't free: compression costs CPU and memory per connection, and with many connections that adds up. For small messages the overhead can outweigh the savings, so you enable it deliberately based on payload size, not by default.
Stop accepting new connections, then tell existing clients you're going away, commonly with close code 1001 (going away), giving them a chance to reconnect elsewhere. Drain in-flight messages, then close the sockets and exit.
Clients help by reconnecting with backoff, so a rolling deploy spreads reconnections across the fleet instead of a stampede. Without a graceful path, a deploy drops every connection at once and every client reconnects simultaneously.
function shutdown(wss) {
wss.close(); // stop new connections
for (const client of wss.clients) {
client.close(1001, "server restarting");
}
}No. TCP guarantees ordered, reliable delivery while the connection is open, but a message in flight when the connection drops can be lost, and there's no built-in acknowledgement that the app processed it.
If you need at-least-once delivery, you build it: give each message an ID, have the receiver acknowledge, and retry unacknowledged messages after a reconnect. Idempotent handlers let you retry safely without double-processing.
Key point: The honest answer, 'the protocol gives ordering, not application-level delivery guarantees, you add acks yourself', indicates real distributed-systems maturity.
Unit-test your message handlers as plain functions by feeding them parsed messages, so most logic needs no live socket. For the connection layer, spin up a real WebSocket server in the test and a client connects to assert on the open/message/close flow.
For manual checks, tools like wscat on the command line or browser devtools let you open a socket and send frames by hand. The key is separating pure message logic from the transport so the bulk of your tests are fast and don't need a network.
# connect and send a frame by hand
npx wscat -c wss://echo.websocket.org
> hello
< helloNetworks in the middle (proxies, load balancers, NAT gateways, mobile carriers) close connections they judge idle to reclaim resources. A WebSocket with no traffic looks idle, so it gets cut even though both ends think it's fine.
The fix is a heartbeat: periodic ping/pong keeps a trickle of traffic flowing so intermediaries don't reap the connection, and it doubles as your dead-connection detector. This is the practical reason nearly every production WebSocket app runs a heartbeat.
A subprotocol is an application-level protocol name negotiated during the handshake with the Sec-WebSocket-Protocol header. The client offers one or more names, and the server picks one, so both sides agree on the message format before any data flows.
Examples include graphql-ws for GraphQL subscriptions and MQTT over WebSocket. It's a clean way to version or select a message contract at connect time instead of guessing from the payload.
// client offers subprotocols, server chooses one
const socket = new WebSocket("wss://example.com", ["graphql-ws", "json.v2"]);
socket.onopen = () => console.log("agreed on:", socket.protocol);WebSocket has no built-in request/response pairing, so you add correlation yourself. Put a unique ID on each message that expects a reply, keep a map of ID to a pending callback or promise, and when a reply arrives carrying the same ID, resolve it. That turns fire-and-forget messaging into ask-and-wait where you need it.
This is the foundation for retries and delivery guarantees: if no ack comes back within a timeout, you resend. Socket.IO ships this pattern as acknowledgement callbacks, but it's a few lines to build on raw WebSocket.
const pending = new Map();
function request(socket, payload) {
const id = crypto.randomUUID();
return new Promise((resolve) => {
pending.set(id, resolve);
socket.send(JSON.stringify({ id, payload }));
});
}
socket.onmessage = (e) => {
const msg = JSON.parse(e.data);
if (pending.has(msg.id)) {
pending.get(msg.id)(msg.result);
pending.delete(msg.id);
}
};Key point: Describing the ID-to-pending-callback map is the answer. It shows you know WebSocket gives you a pipe, not a request/response layer.
A misbehaving or hostile client can flood a connection, so you cap how many messages it may send per window. A token bucket per connection is the common tool: refill tokens over time, spend one per message, and reject or disconnect when the bucket runs dry.
You also cap message size before the payload is buffered, because a giant message is its own denial-of-service vector. Rate limits belong on the server, per connection and often per user, since the client can't be trusted to limit itself.
function makeLimiter(maxPerSec) {
let tokens = maxPerSec;
setInterval(() => { tokens = maxPerSec; }, 1000);
return () => (tokens > 0 ? (tokens--, true) : false);
}
const allow = makeLimiter(20);
socket.on("message", (data) => {
if (!allow()) return socket.close(4029, "rate limit");
handle(data);
});advanced rounds probe protocol internals, scaling design, and production scars. Expect every answer here to draw a follow-up.
The core problem: a client connects to exactly one server, so a message that must reach clients on other servers can't be delivered by a local loop. You put a shared pub/sub layer, commonly Redis, between the servers: each server subscribes, and to broadcast, a server publishes so every server delivers to its own connected clients.
Around that you add horizontal scaling behind a load balancer that supports Upgrade, sticky sessions or externalized connection state, per-node connection limits, and heartbeats so dead connections don't leak. Presence (who's online) and message history usually move to their own stores because they can't live in one node's memory.
Broadcasting across a scaled WebSocket cluster
Without the pub/sub hop, a broadcast only reaches clients on the originating server.
Key point: The one sentence that lands: 'a client is pinned to one server, so cross-server delivery needs a shared pub/sub layer'. Everything else follows from that.
Watch a deeper explanation
Video: How to scale WebSockets to millions of connections (Ably Realtime, YouTube)
The big ones: cross-site WebSocket hijacking (the browser won't enforce same-origin, so the server must validate the Origin header and use unpredictable tokens), unencrypted traffic (always use wss so messages can't be sniffed or tampered with), and missing authorization on every message, not just at connect time.
Add input validation on every inbound message (the connection is a long-lived attack surface), rate limiting and message-size caps to blunt denial-of-service, and short-lived tokens because a WebSocket can outlive a session. Treat each message as untrusted input the same way you'd treat an HTTP body.
Key point: Leading with cross-site WebSocket hijacking and the Origin check is the answer that marks you as someone who's secured a real real-time system.
Classic WebSocket (RFC 6455) upgrades from an HTTP/1.1 request. RFC 8441 defines how to run WebSocket over an HTTP/2 stream using an extended CONNECT, so a WebSocket can share an HTTP/2 connection with other streams instead of monopolizing a whole TCP connection.
HTTP/2 and HTTP/3 also offer their own server-push and streaming primitives, and HTTP/3 runs over QUIC (UDP) to avoid TCP head-of-line blocking. The senior point is that WebSocket isn't the only real-time option anymore, and the right choice depends on your stack and what your proxies support.
A half-open connection is one where one side has gone away (crashed, lost network) but the other side's TCP stack hasn't detected it, so it still believes the connection is live. No close event fires; the socket just sits there.
On a server this is a slow leak: each ghost connection holds memory and a file descriptor, and over time they exhaust resources. The only reliable detection is application heartbeats with a timeout, which is why every serious WebSocket server runs ping/pong and terminates non-responders.
Ordering is guaranteed within one connection by TCP, but a reconnection resets that: messages produced during the gap, or delivered on the new connection, can arrive out of order relative to what the client last saw. You solve it with sequence numbers or a monotonic message ID per stream.
On reconnect the client sends its last-seen ID, and the server replays anything after it in order from a buffer or log. This turns ordering from a transport property into an application property, which is what you need once connections aren't permanent.
Track presence as connection lifecycle: mark a user online on connect, offline on close, and refresh a TTL on each heartbeat so a crashed client expires instead of showing online forever. Because connections spread across servers, presence state lives in a shared store like Redis, not in one node's memory.
Publish presence changes through pub/sub so interested clients get notified. The subtle parts are debouncing flapping connections (mobile networks reconnect constantly) and handling a user with several devices, where they're online until the last connection drops.
Key point: Naming the TTL-refreshed-by-heartbeat trick and the multi-device edge case is what turns this from a toy answer into a design one.
RFC 6455 requires the client to XOR every frame it sends with a random 32-bit masking key. The reason is a cache-poisoning attack: without masking, a malicious page could craft a WebSocket payload that looks like a valid HTTP request to an intermediary proxy and poison its cache.
Masking makes the client's bytes unpredictable to any proxy on the path, so they can't be mistaken for a crafted HTTP request. Server-to-client frames are not masked, because the client isn't the untrusted party in that direction. A server must reject an unmasked client frame.
You can't just fire independent requests; the load is the number of concurrent open connections plus the message rate on each. Tools like k6, Artillery, or a custom client open tens of thousands of connections, hold them, and drive traffic, while you watch server memory, file descriptors, CPU, and message latency.
The metrics that matter differ from HTTP: connections per node before degradation, memory per connection, broadcast fan-out time, and behavior during a mass reconnection (simulate a deploy). Test the reconnection storm specifically, because that's what actually breaks systems in production.
Some corporate proxies and networks still break WebSocket. A resilient client detects a failed upgrade and falls back to HTTP long polling for two-way messaging or SSE for server-to-client streams, keeping the same application message format so higher layers don't care which transport won.
Libraries like Socket.IO do this automatically by starting on long polling and upgrading to WebSocket when it succeeds. Building it yourself means abstracting the transport behind a small interface so your app logic is transport-agnostic.
Connection-centric metrics, not request-centric: current open connections per node, connect and disconnect rates, close codes (a spike in 1006 abnormal closures signals trouble), message throughput and size, and end-to-end message latency. Memory and file-descriptor usage per node because those are what run out.
Alert on reconnection storms, rising half-open connections, and pub/sub lag if you're scaled out. Logging each connection with an ID lets you trace one client's whole session, which is how you actually debug a flaky real-time bug.
Observe first: are connections dropping (watch close codes and disconnect rate), stalling (message latency, back pressure, bufferedAmount), or never establishing (handshake failures at the proxy or load balancer)? Correlate with recent deploys and check the usual suspects: idle timeouts too short, a proxy not passing Upgrade, missing heartbeats, or a reconnection storm hammering the servers.
Then reproduce with a raw client (wscat or devtools) to isolate app logic from the network, fix at the right layer, and confirm with the same metric that showed the problem. The structure, observe, hypothesize, verify, fix, confirm, matters more than any single tool.
Key point: the technical evaluation checks the process; naming close code 1006 and idle timeouts is supporting evidence.
REST is stateless: any server can handle any request, so you scale by adding boxes behind a load balancer. WebSocket is stateful: each connection is pinned to one server for its whole life, so servers hold long-lived state, broadcasts need cross-server coordination, and you can't freely move a live connection.
That statefulness drives everything downstream: sticky sessions or externalized state, pub/sub for fan-out, graceful drain on deploy so you don't drop every connection, and capacity planning by connection count rather than requests per second. It's a different scaling model, not just a bigger one.
| REST API | WebSocket | |
|---|---|---|
| State | Stateless | Stateful, long-lived |
| Scaling unit | Requests per second | Concurrent connections |
| Any server? | Yes | No, pinned to one |
| Broadcast | N/A | Needs pub/sub across servers |
| Deploy | Rolling, easy | Must drain connections |
Don't send them as one frame. WebSocket supports fragmenting a message across continuation frames, and libraries let you set a max message size to reject oversized payloads that could exhaust memory. For genuinely large transfers, chunk the data at the application level and reassemble, or send a reference (a URL) and let the client fetch the blob over HTTP.
The reason to cap size is defensive: an unbounded message is a denial-of-service vector, since the server buffers it before your code sees it. Set a limit, reject beyond it, and stream anything big.
The connection is disposable but the session shouldn't be. You give each session a stable ID stored client-side, and on reconnect the client presents it so the server rebinds the new connection to the same logical session: subscriptions, presence, and any buffered messages carry over.
Combine that with a last-seen message ID so the server replays what was missed during the gap. This decouples session identity from the physical connection, which is exactly what lets a mobile client survive network changes without the user noticing.
RFC 6455 gives you the handshake, framing, full-duplex messaging, and ping/pong control frames. That's the transport. What it deliberately leaves out is everything above: reconnection, acknowledgements and delivery guarantees, rooms and pub/sub, presence, authentication flow, and back-pressure policy.
So real systems either adopt a library (Socket.IO, or managed services) or rebuild these pieces. Knowing the line between what the protocol provides and what your application must own is the distinction this question is checking.
GraphQL subscriptions are the real-time part of GraphQL, and they're usually delivered over WebSocket using a subprotocol like graphql-ws negotiated at connect. The client sends a subscription operation, the server pushes a message every time the subscribed data changes, and either side can complete the subscription.
Underneath, the server ties each subscription to an event source (often the same pub/sub layer you'd use for broadcasting) so a mutation elsewhere triggers the pushed update. It's a structured message contract layered on top of the plain WebSocket transport.
Memory scales with concurrent connections: each open socket holds buffers, per-connection state, and a file descriptor, so hundreds of thousands of idle connections still cost real RAM even with no traffic. CPU scales with message rate and any per-message work: JSON parsing, compression, fan-out to many clients.
The design levers are keeping per-connection state small (push shared state to Redis), using an event-driven runtime that handles many connections per thread, being deliberate about permessage-deflate (it trades CPU for bandwidth), and batching or throttling broadcasts so a hot room doesn't saturate a core.
Key point: The framing the technical value is: 'memory tracks connection count, CPU tracks message rate'. It shows you know which knob to turn for which bottleneck.
Each WebSocket server subscribes to a Redis channel on startup and keeps its own local clients in memory. To broadcast, a server publishes the message to the channel instead of looping only its own clients. Redis delivers that message to every subscribed server, and each server then sends it to its own connected clients.
So the fan-out is two-level: Redis fans out to servers, servers fan out to clients. The trade-off is an extra network hop and Redis as a dependency, but it's what lets a message from a client on server A reach a client on server B.
import { createClient } from "redis";
const sub = createClient();
const pub = createClient();
await sub.subscribe("broadcast", (message) => {
for (const client of wss.clients) {
if (client.readyState === 1) client.send(message);
}
});
// to broadcast from any server:
function broadcast(data) { pub.publish("broadcast", data); }Key point: The clean summary: 'Redis fans out to servers, each server fans out to its own clients'. Two levels, one dependency.
Give each stream a monotonically increasing sequence number and have the server keep a short buffer or log of recent messages. The client remembers the last sequence it saw. On reconnect it sends that number, and the server replays everything after it in order before resuming live delivery.
This bridges the gap where a client was disconnected. It works with an idempotent client so a replayed message it already applied does no harm, and the buffer has a bound so it can't grow forever, at which point a client that's too far behind does a full resync instead.
// client resumes from its last-seen sequence
function resume(socket, lastSeq) {
socket.send(JSON.stringify({ type: "resume", lastSeq }));
}
// server replays the tail of its buffer, then goes live
function onResume(socket, lastSeq, buffer) {
for (const msg of buffer.filter((m) => m.seq > lastSeq)) {
socket.send(JSON.stringify(msg));
}
}Key point: Naming the last-seen sequence plus a bounded server buffer, with a full resync fallback, is the answer that indicates real production experience.
WebSocket wins when you need low-latency, two-way messaging over one connection: chat, live dashboards, collaborative editing, multiplayer games. It trades the simplicity and caching of stateless HTTP for a persistent, stateful connection you have to keep alive and scale. Where it loses is one-directional server-to-client streaming, where Server-Sent Events are simpler and auto-reconnect for free, and peer-to-peer media, where WebRTC fits better. Knowing these trade-offs out loud is itself an interview signal: it shows you pick transports on merits, not habit.
| Transport | Direction | Runs over | Best at |
|---|---|---|---|
| WebSocket | Full-duplex (both ways) | One TCP connection | Chat, live collaboration, games |
| HTTP long polling | Client asks, server replies | Repeated HTTP requests | Fallback where WebSocket is blocked |
| Server-Sent Events | Server to client only | One HTTP response stream | Live feeds, notifications, tickers |
| WebRTC | Peer to peer | UDP (usually), data channels | Audio, video, direct P2P data |
Prepare in layers, and practice out loud. Most WebSocket rounds move from protocol concepts to a live coding task to a scaling or debugging discussion, so rehearse each stage rather than only reading answers.
The typical WebSockets interview flow
Earlier rounds increasingly run as AI coding interviews. The concepts on this page are what gets probed at every stage.
10 questions, about 6 minutes. Score 70% or higher to earn a shareable certificate.
Hyring builds the AI Coding Interviewer that runs live technical rounds in 20+ languages for 5,000+ hiring teams. These WebSockets questions and scoring notes reflect what actually gets asked and evaluated inside the interviews we host.
See how the AI Coding Interviewer works