WebSocket and Real-Time Web Applications
Chat, collaboration, push notifications, and real-time dashboards need server-initiated delivery, which HTTP handles poorly. This article explains WebSocket fundamentals, when to use it versus SSE or polling, connection management, broadcasts and rooms, Redis Pub/Sub scaling across instances, and common real-time architecture choices.
Key Points
- Topic Focus: Next.js、TypeScript
- Suitable for: indie hackers, full-stack engineers, AI application developers
- Core value: quickly understand background, practical paths, common pitfalls, and engineering advice
WebSocket and Real-Time Web Applications
Anyone building chat rooms, collaborative editing, or real-time dashboards eventually hits the same wall: HTTP is request-driven, so the server cannot proactively push messages to the browser. The client can only keep asking, "Any new messages?" This is wasteful and slow. WebSocket was created to overcome this limitation.
1. Why HTTP Falls Short for Real-Time Applications
HTTP is unidirectional: the client initiates a request, and the server responds. This model is perfect for documents, forms, and APIs, but it is fundamentally unsuitable when the server has an event that it must report to the client immediately.
Real-time applications have three hard requirements:
- Bidirectional communication: The client must send messages, such as typing or position updates, while the server must also push messages, such as a reply from another user or a data change.
- Low latency: The interval between an event occurring and the user seeing it should be measured in milliseconds, not seconds.
- Server push: Events do not wait; the server must be able to initiate delivery.
HTTP is disadvantaged in all three respects. It uses a request-response model in which the server can only respond passively. You can certainly call fetch repeatedly, but that is polling, and the next section explains how wasteful it can be.
2. Comparing Three Options: Polling, SSE, and WebSocket
There are usually three ways to implement server-pushed messages, each with its own tradeoffs.
Polling: The client sends a request every N seconds to ask the server, "Anything new?"
- Advantages: Extremely simple to implement, uses standard HTTP, and requires no special infrastructure.
- Disadvantages: Most requests return nothing and accomplish no useful work; when a message does exist, it takes an average of N/2 seconds to retrieve; connections are repeatedly established, wasting QPS capacity.
Long Polling: The server holds a request open until a message is available or the request times out. It is smarter than ordinary polling, but after every response the client must immediately establish another connection. Fundamentally, it remains a compromise built on HTTP.
SSE (Server-Sent Events): An HTTP-based, unidirectional push channel. The server continuously writes data: ... text frames to a long-lived connection, and the browser receives them through EventSource.
- Advantages: Simple protocol, automatic reconnection, standard HTTP ports, and good compatibility with HTTP/2.
- Disadvantages: Supports only one-way server → client communication; uses a text protocol, so binary data must be encoded; browser
EventSourceconnections are limited to six per origin.
WebSocket: Provides full-duplex bidirectional communication over a single TCP connection, allowing either side to send messages at any time after the handshake.
- Advantages: Bidirectional, low latency, binary-friendly, and low protocol overhead with 2-14-byte frame headers.
- Disadvantages: Greater protocol complexity; you must handle connection management, reconnection, and authentication yourself; additional requirements for infrastructure such as load balancers, proxies, and CDNs.
In short: Prefer SSE for unidirectional push; use WebSocket only for bidirectional interaction.
3. How WebSocket Works: Handshakes, Frames, and Full-Duplex Communication
WebSocket is not a completely separate protocol from the outset. It begins with an elegant protocol upgrade.
Handshake: The connection starts as an ordinary HTTP request with upgrade headers:
GET /ws HTTP/1.1
Host: example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
If the server accepts, it returns 101 Switching Protocols:
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
From that moment onward, the TCP connection no longer carries HTTP. Both sides switch to WebSocket's binary frame protocol.
Frame: The smallest unit of WebSocket communication. A frame contains an opcode indicating whether it is text, binary, ping, pong, or close data, along with the payload length and data. A text message with opcode 1 is a UTF-8 byte stream. Ping and pong are built-in protocol heartbeat instructions.
Full-duplex communication: After the handshake, both ends of the connection can send frames at any time without blocking each other. This is the foundation of real-time interaction: while the user is typing, the server can push "the other user is typing," and the client can send its own state over the same connection.
Client Server
|--- HTTP Upgrade ----------->|
|<----- 101 -----------------|
|=== (full-duplex frames) ===|
|<--- push: "user joined" ----|
|---- send: "typing..." ---->|
|<--- push: new message ------|
4. When to Use WebSocket
Do not make WebSocket the default merely because it looks more advanced. It has clear use cases and equally clear cases where it should not be used.
Use it for:
- Chat / IM: Bidirectional communication, frequent state changes, and dense message traffic.
- Multi-user collaboration: Collaborative editing such as Figma or Google Docs and shared whiteboards, where other users' actions must be synchronized in real time.
- Multiplayer games: High-frequency, bidirectional exchange of positions, actions, and state where latency matters.
- Real-time dashboards: Frequently refreshed metrics, although SSE is sufficient for read-only dashboards without controls.
Do not use it for:
- Pure notification push, such as order status updates or system alerts. SSE is simpler.
- Occasional data retrieval. An ordinary HTTP request is sufficient.
- Content that must be cached through a CDN. WebSocket is stateful and cannot be cached.
The criterion is straightforward: WebSocket is worth the complexity only when the client also needs to send data to the server frequently.
5. Connection Management: Reconnection, Heartbeats, and Authentication (The Real Challenge)
Writing a WebSocket demo is easy. In a production-ready connection, 90% of the code handles failures.
Automatic reconnection (exponential backoff): Network instability, service restarts, and load-balancer switching can all terminate connections. The client must reconnect automatically, but it cannot retry so aggressively that it overwhelms the server. Use exponential backoff with jitter:
function connect() {
const ws = new WebSocket(`wss://api.example.com/ws?token=${getToken()}`);
ws.onclose = () => {
const delay = Math.min(1000 * 2 ** retries, 30000) + Math.random() * 1000;
retries += 1;
setTimeout(connect, delay);
};
ws.onopen = () => { retries = 0; };
}
Heartbeats (ping/pong liveness checks): TCP does not tell you that the peer is effectively dead, such as when a NAT entry expires or Wi-Fi disconnects silently. Application-layer heartbeats are essential:
- The client sends a ping every 30 seconds, and the server immediately replies with a pong.
- If no pong arrives for N consecutive attempts, proactively close the connection to trigger reconnection.
- The server must likewise monitor client heartbeats and release resources after a timeout; otherwise, connections will leak.
Authentication: The WebSocket handshake uses HTTP, so tokens are usually placed in one of two locations:
- Query parameter:
wss://.../ws?token=xxx. This is simple but sensitive because it appears in access logs. - The
Sec-WebSocket-Protocolsubprotocol header or aCookie. This is cleaner but requires server support.
Note that the browser's WebSocket constructor cannot set custom headers. Therefore, the "Authorization Bearer" approach does not work in a browser-only client. If custom headers are required, the server must exchange a one-time ticket for a ws session, or the application can simply use a Cookie.
6. Messaging Patterns: Broadcasts, Rooms, and Direct Messages
Establishing a connection is only the beginning. The real design question is how messages are routed. There are three classic patterns:
Broadcast: Send one message to every connection. This is suitable for global notifications and system-wide events. It is the simplest pattern, but also the bluntest because not every user should receive every message.
Room / Channel: Group connections and deliver messages by group. Chat rooms, collaborative workspaces, and live-stream comments all rely on this pattern. A connection can belong to multiple rooms simultaneously.
const rooms = new Map<string, Set<WebSocket>>();
function join(roomId: string, ws: WebSocket) {
if (!rooms.has(roomId)) rooms.set(roomId, new Set());
rooms.get(roomId)!.add(ws);
}
function broadcastRoom(roomId: string, msg: string) {
for (const ws of rooms.get(roomId) ?? []) {
if (ws.readyState === WebSocket.OPEN) ws.send(msg);
}
}
Direct / DM: Send a message to a specific user. This requires maintaining a userId -> WebSocket mapping. Because the same user may be signed in on multiple devices, the actual mapping is userId -> Set<WebSocket>.
In practice, systems nearly always combine these three patterns: collaboration tools use "rooms + direct messages," IM uses "direct messages + rooms (group chats)," and games use "rooms + broadcasts (within the same match)."
7. Scaling: Redis Pub/Sub Across Multiple Instances
A single instance can support only a limited number of connections, from tens of thousands to hundreds of thousands depending on memory and file descriptors. Once the system scales horizontally to multiple instances, a counterintuitive but critical problem appears:
User A ──ws──> Instance 1
User B ──ws──> Instance 2
When A sends a message to B, A's connection is on Instance 1, but B's connection is on Instance 2. B does not exist in Instance 1's local rooms, so the message cannot be delivered.
The solution is a cross-instance message bus. Redis Pub/Sub is the de facto standard:
┌─────────── Redis Pub/Sub ───────────┐
│ channel: room:42 │
▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔
│ │
subscribe subscribe
│ │
Instance 1 Instance 2
(A and C connect here) (B connects here)
Each instance:
- Locally tracks the connections attached to that instance.
- Subscribes to the relevant Redis channels.
- When broadcasting, uses
PUBLISHon the channel. Every instance receives the message and delivers it to its own local connections.
// Publish a message to Redis instead of sending it locally
redis.publish(`room:${roomId}`, JSON.stringify(msg));
// Each instance subscribes and delivers the message locally
redis.subscribe(`room:${roomId}`, (raw) => {
for (const ws of localRooms.get(roomId) ?? []) ws.send(raw);
});
More complex scenarios involving message persistence, offline messages, or ACKs may use NATS, Kafka, or a dedicated real-time gateway such as Ably, Pusher, or Centrifugo. However, Redis Pub/Sub is the first step for most small and medium-sized teams because it is capable, inexpensive, and reliable enough.
Another frequently overlooked consideration is sticky sessions. If instances hold in-memory state, the load balancer must use hash-based routing to send the same user's requests to the same instance. WebSocket is a long-lived connection, however, and after the handshake it no longer goes through the load balancer. As long as the load balancer supports WebSocket upgrades, as most modern load balancers do, sticky sessions are not a major concern.
8. Alternatives: Socket.io or Native WS
At this stage of architecture selection, almost everyone asks: Socket.io or native WebSocket?
Socket.io: The de facto wrapper standard. It provides:
- Automatic reconnection and heartbeats out of the box.
- Rooms and namespaces as built-in abstractions.
- Fallback transport: Automatically falls back to long polling when WebSocket is unavailable, which is useful on restricted networks.
- ACK support, binary streams, and middleware.
The tradeoff is that it uses its own protocol. It is not raw WebSocket, so a Socket.io client must connect to a Socket.io server. It also has a nontrivial bundle size and slightly lower performance than native WebSocket.
Native WS (ws for Node.js): Uses the standard WebSocket protocol directly. It is lightweight, fast, and controllable. However, reconnection, heartbeats, rooms, and broadcasts must all be implemented manually.
Practical recommendations:
- Small teams that prioritize speed and need compatibility with older browsers or restricted networks: Socket.io. It eliminates tedious details so the team can focus on business logic.
- Medium and large teams that prioritize performance and control and target modern browsers: native WS + a thin custom abstraction. Implement reconnection and heartbeats as reusable hooks, and use the Map-based room pattern shown above.
- Hybrid architecture: Use Socket.io for application traffic and a Redis adapter for multi-instance broadcasting. Socket.io officially provides
@socket.io/redis-adapter. This is the simplest scaling path.
A common misconception is that "Socket.io is just WebSocket with a few extras." It is actually a separate protocol built on WebSocket, with polling as its fallback. A Socket.io client cannot connect to a native ws server, and vice versa. Do not mix them.
Summary
- HTTP is request-driven and cannot support proactive server push. Real-time applications require bidirectional communication, low latency, and server push, which is a fundamentally different level of demand.
- The rule of three: Use SSE for unidirectional push, WebSocket for high-frequency bidirectional interaction, and ordinary HTTP for occasional data retrieval.
- WebSocket's value lies in its full-duplex frames after the handshake: low protocol overhead, low latency, and strong binary support, at the cost of more complex connection management.
- Ninety percent of production work lies in failure paths: exponential-backoff reconnection, application-layer heartbeats, and secure authentication, with tokens passed through a query parameter or Cookie because browsers cannot set custom headers. A server that does not manage heartbeats will eventually be overwhelmed by dead connections.
- Multiple instances require a message bus: Redis Pub/Sub lets connections on any instance receive broadcasts and is the entry point for horizontal scaling.
Key takeaway: Treating WebSocket as "faster HTTP" is where most real-time architectures start to unravel. It is not a replacement for request-response communication, but a stateful, long-lived connection. Choose it only if you truly need bidirectional communication and are genuinely willing to pay the cost of connection management.
Related
Related Content
Article
Is Next.js App Router a Good Fit for a Technical Community?
Technical trends, engineering practices, and independent development perspectives centered on Next.js and React.
Article
SEO Design for a Content Community with Next.js
Technical trends, engineering practices, and an indie developer's perspective on Next.js and TypeScript.
Article
How Should a Technical Community Design Its Tag System and Recommendation Feed?
Centered on independent development, Next.js technology trends, engineering practices, and the indie developer perspective.
Article
The Engineering Value of TypeScript in AI Application Frontends
Technical trends, engineering practices, and an independent developer perspective on TypeScript and React.