WebSockets in Production: Handshakes, Frames, Authentication, Backpressure, and Scaling
WebSockets give a browser and server a long-lived bidirectional channel, which is ideal for chat, collaboration, live control, and other interactive realtime features.
The browser API looks small:
const socket = new WebSocket("wss://api.example.com/realtime");
socket.addEventListener("message", function (event) {
console.log(event.data);
});
socket.send("hello");
Production WebSockets are not small. You still need to design the opening handshake, authentication, per-message authorization, connection lifecycle, application protocol, slow-client behavior, reconnect recovery, proxy timeouts, and cross-node fan-out.
WebSocket begins as an HTTP handshake
RFC 6455 defines an opening handshake that starts as an HTTP request.
For the traditional HTTP/1.1 path, the client requests a protocol upgrade with headers such as:
GET /realtime HTTP/1.1
Host: api.example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: ...
Sec-WebSocket-Version: 13
Origin: https://app.example.com
A successful server responds with 101 Switching Protocols and the computed Sec-WebSocket-Accept value.
After that, the underlying connection carries WebSocket frames instead of normal HTTP request/response messages.
This matters operationally because load balancers, reverse proxies, gateways, WAFs, and timeouts must understand the upgraded long-lived connection.
Use wss:// for production traffic
ws:// is unencrypted.
wss:// runs WebSocket over TLS and should be the normal production choice for authenticated or sensitive browser traffic.
TLS protects transport confidentiality and integrity. It does not authorize the user to join a room, read a tenant stream, or execute an application command.
Authentication and authorization remain application responsibilities.
WebSocket can also be bootstrapped over HTTP/2
The original RFC 6455 handshake uses HTTP/1.1 Upgrade.
RFC 8441 defines Extended CONNECT for bootstrapping WebSocket on an HTTP/2 stream when both peers support it.
That allows WebSocket traffic to coexist with other HTTP/2 streams in compatible infrastructure.
Do not assume every proxy, CDN, gateway, or client library uses the same HTTP-version path. Verify the exact production chain.
Once established, the application still uses normal WebSocket protocol semantics.
Frames are the protocol delivery unit
RFC 6455 defines frame opcodes for:
- text
- binary
- continuation
- close
- ping
- pong
Messages can be fragmented across frames. Most libraries hide fragmentation from application code, but frame semantics still matter when setting:
- maximum payload size
- compression
- gateway limits
- binary/text handling
- security controls
Do not accept unbounded messages simply because normal clients send tiny JSON objects.
Client frames are masked, but masking is not encryption
RFC 6455 requires client-to-server frames to be masked. Servers do not mask frames sent to clients.
Masking is a protocol requirement intended to protect intermediaries from specific attack classes. It is not a confidentiality mechanism.
TLS is what protects sensitive traffic on the network.
A compliant server should reject invalid framing instead of trying to tolerate malformed protocol data.
Text and binary messages are both valid choices
Text frames are convenient for readable JSON:
{
"type": "chat.message.send",
"request_id": "req_123",
"payload": {
"conversation_id": "c7",
"text": "hello"
}
}
Binary frames can be useful for compact serialization or binary realtime state.
But binary is not automatically worth the complexity. For many business applications, a clear versioned JSON protocol is an excellent default until profiling shows serialization or bandwidth is a real bottleneck.
Define an application protocol above WebSocket
WebSocket gives you a transport, not business semantics.
A useful event envelope can include:
{
"version": 1,
"type": "order.updated",
"event_id": "evt_900",
"payload": {}
}
Client commands may include a request ID:
{
"version": 1,
"type": "chat.message.send",
"request_id": "req_901",
"payload": {}
}
Define explicitly:
- message type names
- schema/version strategy
- request/event IDs
- success/error responses
- authorization scope
- acknowledgement rules
- maximum payload size
- ordering semantics
Without this, several client versions quickly turn one socket into an undocumented collection of JSON shapes.
Use subprotocol negotiation only when it represents a real contract
The opening handshake supports Sec-WebSocket-Protocol.
A client can offer application subprotocols such as:
chat.v2, chat.v1
and the server selects one supported protocol.
This is useful when protocol negotiation genuinely matters. For ordinary application event evolution, one application-level version field may be easier than inventing a new subprotocol for every message change.
Validate Origin on browser handshakes
Browser WebSockets can carry existing cookies during the handshake. A malicious website may therefore attempt to open a WebSocket to your application using the victim's authenticated browser session.
RFC 6455 includes the Origin header as part of browser-origin protection, and OWASP recommends validating it against an explicit allowlist.
Accept exact trusted origins such as:
https://app.example.com
Avoid permissive wildcard or substring matching.
Origin validation protects one security boundary. It does not replace authentication.
WebSocket has no built-in user identity model
RFC 6455 defines transport semantics, not your login system.
Common browser authentication approaches include:
- secure session cookies
- short-lived connection credentials
- framework-supported authentication during handshake
Be careful with bearer credentials placed in URL query strings because URLs may appear in proxy logs, monitoring, diagnostics, and error reports.
When URL credentials are unavoidable, keep them short-lived and scoped and ensure infrastructure does not log them.
Authenticate the connection, authorize every sensitive operation
An authenticated user is not automatically authorized for every room, tenant, or command.
For this command:
{
"type": "conversation.subscribe",
"conversation_id": "private-c7"
}
verify that the user currently belongs to private-c7.
Do the same for:
- sending messages
- joining topics
- editing data
- subscribing to tenant events
- triggering administrative actions
Never trust a user, room, or tenant ID merely because it came through an authenticated socket.
Long-lived sockets need revocation behavior
A session can become invalid while the socket remains open.
Plan what happens when:
- user logs out everywhere
- password changes
- account is disabled
- tenant membership is removed
- permission changes
For security-sensitive systems, close or re-authorize affected connections instead of waiting indefinitely for the network to disconnect them.
A mapping from session/user identity to active connections makes revocation possible.
Ping and Pong help detect dead connections
RFC 6455 defines Ping and Pong control frames.
Ping can act as a keepalive and liveness check. A peer receiving Ping responds with Pong according to protocol rules.
This matters because mobile networks, sleeping laptops, NAT mappings, and intermediary failures can leave connections that appear open but are no longer useful.
Use reasonable heartbeat intervals. Extremely aggressive heartbeats waste server work, bandwidth, and battery.
Application heartbeat is different from protocol Ping/Pong
Browser JavaScript does not expose RFC control-frame Ping/Pong in the same way many server libraries do.
If your product needs application-level liveness semantics, you may send a normal application message:
{ "type": "app.ping", "id": "p123" }
and expect an application response.
Keep the distinction clear when debugging connection health.
Close gracefully when possible
RFC 6455 defines a closing handshake using Close frames.
Use meaningful close behavior for cases such as:
- normal shutdown
- authentication failure
- policy violation
- server maintenance
- unsupported application protocol
Do not put sensitive internal details into close reasons that may surface in client logs.
Clients should distinguish unexpected network failure from deliberate terminal closes where immediate reconnect would be useless.
Reconnect with bounded exponential backoff and jitter
A deployment or gateway failure can disconnect a huge client population at once.
If every client retries immediately, the recovering service gets a thundering herd.
A healthier strategy is:
attempt 1 → short randomized delay
attempt 2 → longer randomized delay
attempt 3 → longer again
later attempts → capped maximum delay
Reset the backoff after a meaningful healthy period.
Explicit policy/authentication closes and transient network failures should not necessarily use the same reconnect policy.
Reconnect is not state recovery
Opening a new socket only restores the transport.
The user may have missed durable state while disconnected.
A recovery flow can use a cursor:
reconnect
report last known event ID
fetch or replay missing durable state
resume live delivery
For state-oriented applications, refetching the canonical current object may be simpler than replaying every intermediate event.
Make disconnects normal and recoverable instead of trying to make one socket immortal.
The stable browser WebSocket API has no built-in backpressure
MDN explicitly warns that the standard WebSocket interface does not provide automatic backpressure.
send() queues bytes, and bufferedAmount reports how much data is waiting to be transmitted.
When bufferedAmount keeps rising, clients should stop producing unlimited work.
Possible policies include:
- coalesce rapidly changing state
- drop disposable events
- pause producers
- reconnect and recover durable state later
bufferedAmount is a signal; your application still defines the high-water policy.
Server-side queues must be bounded too
A fast producer sending to a slow client can create an ever-growing gateway queue.
Set limits by bytes, messages, and/or age.
When a connection falls too far behind:
- drop obsolete ephemeral data
- coalesce latest-state updates
- disconnect and recover durable messages later
- reject excess producer work where appropriate
One slow connection should never have permission to consume unbounded server memory.
Compression is not automatically a win
permessage-deflate can reduce payload size but costs CPU and memory and can create security concerns in applications that compress secrets together with attacker-influenced content.
Before enabling compression globally, measure:
- typical message size
- bandwidth saved
- CPU impact
- memory per connection
- sensitive-data risk
Tiny JSON events may not justify the overhead.
Limit payload size and message rate
Treat every connected client as untrusted input.
Enforce limits on:
- message bytes
- decoded payload complexity
- events per second
- expensive commands
- subscription count
- room joins
Persistent connections can bypass normal HTTP request patterns and generate very high application work if the realtime layer has no equivalent rate limiting.
Validate every application message
Do not dispatch arbitrary decoded JSON directly into business handlers.
Validate:
- allowed
type - payload schema
- string length
- IDs
- enum values
- numeric ranges
Unknown or invalid messages should produce a predictable protocol error without crashing the connection process.
Horizontal scaling requires cross-gateway delivery
Suppose connections are distributed like this:
user A → gateway 1
user B → gateway 2
user C → gateway 3
An event created elsewhere must reach whichever gateway currently owns the recipient's connection.
Common designs use:
- broker/pub-sub between gateway nodes
- a connection-directory/routing layer
- room/topic fan-out infrastructure
Keep irreplaceable user state outside gateway memory. If gateway 2 disappears, user B should reconnect elsewhere and recover durable state.
Sticky routing does not solve fan-out
A live socket naturally remains attached to one server for its lifetime.
Sticky reconnection may simplify some deployments, but it does not answer:
How does gateway 1 send an event to someone connected to gateway 7?
You still need cross-node routing or fan-out.
Proxies and load balancers need long-lived connection settings
Healthy sockets can be terminated by infrastructure because of:
- idle timeout
- maximum connection age
- rolling deployment
- proxy restart
- upstream health changes
Verify:
- WebSocket Upgrade/CONNECT support
- read/idle timeouts
- TLS termination
- maximum connection limits
- connection draining
Even perfect infrastructure will eventually close connections. Clients must recover.
Drain connections during deployments
A graceful gateway rollout can:
- stop accepting new connections on an old instance
- keep current sockets briefly while new instances become ready
- optionally signal clients to reconnect
- enforce a maximum drain window
- close remaining connections
Combined with reconnect jitter, draining reduces deployment-induced spikes.
Observe the full lifecycle
Useful metrics include:
- active connections
- new connections/sec
- reconnect rate
- handshake/auth failures
- Origin rejections
- close codes
- abnormal disconnects
- messages/sec by type
- inbound/outbound bytes
- per-connection queue pressure
- slow-client disconnects
- fan-out latency
- gateway CPU/memory/event-loop or thread saturation
Normal HTTP access logs usually capture only the opening handshake. Add explicit WebSocket telemetry.
Avoid logging complete private message payloads or authentication tokens.
A practical production flow
Connect
- Open
wss://. - Validate browser
Origin. - Authenticate identity/session.
- Negotiate protocol/version if needed.
- Bind connection to user/session/device.
While connected
- Validate every message schema.
- Authorize every sensitive operation.
- Rate-limit abusive event types.
- Keep input/output queues bounded.
- Monitor liveness.
- Deliver only events the user is currently allowed to receive.
Disconnect and recover
- Record the close reason.
- Clean connection ownership/presence.
- Retry transient failures with backoff and jitter.
- Reauthenticate.
- Fetch/replay missed durable state from a cursor or version.
- Resume live delivery.
That is the difference between a WebSocket demo and a production WebSocket system.
When WebSocket is not the best choice
Use something simpler when it fits.
SSE may be better when the server mainly pushes events and client commands already use normal HTTP.
Polling may be better when updates are rare and seconds of latency are fine.
WebRTC is the natural browser technology when peer audio/video or direct peer data is central.
WebTransport is worth evaluating when independent streams or datagrams materially improve the protocol.
Choose WebSocket when sustained bidirectional client-server communication is actually required.
Production checklist
Before launching WebSockets, verify:
- production traffic uses
wss:// - browser
Originis explicitly allowlisted - authentication is secure during handshake
- authorization is enforced per command/subscription
- session revocation can close live sockets
- messages are versioned and schema-validated
- payload/rate/subscription limits exist
- dead connections are detected
- reconnect uses backoff and jitter
- missed durable state can be recovered
- browser and server buffers are bounded
- proxy/load-balancer timeouts are reviewed
- deployments drain connections where practical
- cross-node fan-out works
- telemetry includes close codes, queue pressure, auth failures, and reconnects
WebSocket's biggest strength is also its biggest responsibility: once you replace short HTTP interactions with a long-lived bidirectional session, connection state, security, failure recovery, and flow control become part of your application protocol.
References
BUILDING A SAAS PRODUCT?
Explore Lofingo’s end-to-end SaaS platforms, multi-tenant cloud architecture, and high-concurrency backend engineering.
EXPLORE SAAS SERVICES →