WebSockets: A Persistent Bidirectional Communication Channel
- date
- category
- Backend
- also in
- Frontend · Networking
- reading
- 2 min / 427 words
SSE still keeps the model:
client -> request
server -> stream of responses/events
WebSocket changes the abstraction.
Once the connection is established, both sides can send messages independently:
client <-> server
There is no longer a relation:
one request -> one response
It starts with a handshake
The classic WebSocket handshake uses an HTTP upgrade:
GET /socket HTTP/1.1
Host: example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: ...
Sec-WebSocket-Version: 13
The server accepts:
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: ...
From that moment the communication does not consist of HTTP requests and responses.
The connection carries WebSocket frames.
Frames, not endpoints
The browser API is simple:
const socket =
new WebSocket("wss://example.com/socket");
socket.onmessage = event => {
console.log(event.data);
};
socket.send(JSON.stringify({
type: "cursor.move",
x: 120,
y: 80
}));
WebSocket does not know what cursor.move is.
That is the application protocol.
A native WebSocket mainly gives us:
connection
message framing
text/binary payloads
ping/pong at protocol level
close handshake
The semantics of the messages are ours to define.
The connection is state
In classic HTTP:
request 1 -> instance A
request 2 -> instance C
request 3 -> instance B
With WebSocket:
client ───────── instance A
persistent connection
That changes the infrastructure architecture.
If an event appears on instance B while the client sits on A:
instance B
|
v
shared pub/sub
|
v
instance A
|
v
client
A fan-out layer becomes necessary:
- Redis Pub/Sub,
- NATS,
- Kafka,
- an application broker,
- a connection registry of your own.
WebSocket by itself does not solve distributing events between backend instances.
A deploy stops being just a process restart
If one instance holds 50 000 active connections, shutting it down means 50 000 reconnects.
What is needed:
stop accepting new connections
|
v
connection draining
|
v
clients reconnect elsewhere
|
v
terminate old instance
And on top of that:
- load balancer idle timeout,
- heartbeat,
- reconnect backoff,
- connection storms,
- state restoration.
A persistent connection is a feature of the application and of operations.
Backpressure
The client may send or receive data more slowly than the other side produces it.
The browser WebSocket API exposes bufferedAmount, the number of bytes queued for sending.
If:
producer rate > network/consumer rate
the queue grows.
The application has to decide:
- wait,
- aggregate messages,
- drop stale updates,
- close the connection.
In realtime "latest state wins" is often better semantics than reliably delivering every historical change.
WebSocket is not Socket.IO
This one matters.
Native WebSocket:
application protocol
|
v
WebSocket protocol
|
v
transport
Socket.IO:
application events
|
v
Socket.IO protocol
|
v
Engine.IO
|
v
WebSocket / HTTP long-polling
Socket.IO gives a higher layer:
socket.emit("order.updated", order);
socket.on("order.updated", handler);
plus mechanisms such as:
- reconnect,
- acknowledgements,
- rooms,
- namespaces,
- broadcasting,
- fallback transport.
If WebSocket is unavailable, Socket.IO can fall back to HTTP long-polling.
Which means:
Socket.IO client
!=
plain WebSocket client
They are not wire-compatible just because Socket.IO can use WebSocket as a transport.
So comparing "Socket.IO vs WebSocket" mixes two layers.
The better question is:
do I need a low-level message channel, or a ready realtime protocol and framework?
Is WebSocket the end of the story?
WebSocket gives one reliable, ordered communication channel.
But not every realtime case needs:
reliable + ordered + every message
If we send a player position 60 times per second, retransmitting an old position may be less useful than delivering the newest one.
Sometimes we need many independent streams, and data that is allowed to be lost.
That is where WebTransport begins.