How WebSockets Work
A different protocol
HTTP is request-response: the client sends a request, the server sends a response, the connection (logically) closes. SSE stretches this — the server keeps sending data — but the client still cannot send anything back on the same connection.
WebSockets are different. After an initial HTTP handshake, the connection upgrades to a persistent, full-duplex channel. Both sides can send messages at any time, without waiting for the other.
The upgrade handshake
A WebSocket connection starts as a regular HTTP request with special headers:
GET /ws HTTP/1.1
Host: localhost:3000
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13 The server responds with:
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo= After this handshake, the connection is no longer HTTP. It is a WebSocket connection. The HTTP request was just the entry point.
Full-duplex communication
After the upgrade, both sides can send messages independently:
Client: "Create task: Design login page"
Server: "Task created: task-5"
Server: "Alice joined board-1" ← server pushes without a request
Client: "Move task-3 to Done"
Server: "Task moved: task-3"
Server: "Bob is typing..." ← another unsolicited push There is no request-response pairing. Messages flow in both directions independently. This is what “full-duplex” means.
Frames, not requests
HTTP sends complete request/response pairs. WebSockets send frames: small messages that can be text or binary. Each frame is independent — there is no correlation between a message sent and a message received.
In code, this looks like event handlers:
// Client-side
const ws = new WebSocket("ws://localhost:3000/ws");
ws.onopen = () => {
console.log("Connected");
ws.send(JSON.stringify({ type: "join", boardId: "board-1" }));
};
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
console.log("Received:", data);
};
ws.onclose = () => {
console.log("Disconnected");
}; The client sends messages with ws.send(). The server sends messages by writing to the socket. Neither side waits for a response — they listen for messages independently.
ws:// vs wss://
WebSocket URLs use ws:// (unencrypted) and wss:// (encrypted, like HTTPS). In production, always use wss://.
// Development
const ws = new WebSocket("ws://localhost:3000/ws");
// Production
const ws = new WebSocket("wss://api.example.com/ws"); When to use WebSockets instead of SSE
Use WebSockets when: the client needs to send frequent messages (chat, games, collaborative editing), you need binary data support, or you need the lowest possible latency.
Use SSE when: the server pushes updates and the client only listens, you want automatic reconnection, or you want the simplicity of text events.
For our task board: SSE handles “board updates” (server pushes task changes). WebSockets handle “presence” (who is online, typing indicators) because the client needs to send “I am typing” messages to the server.
Exercises
Exercise 1: Open a WebSocket connection in the browser console: new WebSocket("ws://localhost:3000/ws"). The connection will fail because we have not built the server yet — but observe the handshake attempt in the Network tab.
Exercise 2: Compare the SSE EventSource API with the WebSocket API. Which is simpler? (Answer: EventSource — it has built-in reconnection and event types. WebSocket is more flexible but requires more client code.)
What happens during the WebSocket upgrade handshake?