WebSocket Message Protocol Design #
A WebSocket connection gives you a reliable, ordered pipe of frames and nothing else. There are no methods, no status codes, no headers after the handshake, no request ids and no content negotiation. Every real-time application therefore ends up designing an application protocol on top — deliberately or by accident. The accidental version is recognisable: a dozen message shapes that each solved one problem, errors that arrive as strings with no indication of which action failed, clients that break when a field is renamed, and a reconnect path that cannot resume because nothing carries a sequence number. It works until you need to add tracing, retries, versioning or replay, and then every one of those features needs a special case per message type.
This area covers the protocol layer: the envelope every frame shares, how requests find their replies, how pushes are ordered and resumed, how errors are reported without tearing down the connection, and how the protocol evolves while clients stay connected for days. It sits between the transport concerns in Backend WebSocket Connection Management — heartbeats, backpressure, authentication — and the application logic that handlers implement.
Prerequisites #
Before designing the protocol, the connection underneath it should already be sound. A server that cannot detect dead peers or bound its send buffers will lose protocol messages in ways no envelope can fix. Specifically:
- Heartbeats and dead-peer detection are in place, as described in Connection Lifecycle & Heartbeats, so that “no reply” reliably means “slow server” or “lost connection” rather than “the socket died an hour ago”.
- Outbound buffering is bounded, following WebSocket Backpressure & Flow Control, so a slow client cannot make protocol-level timeouts meaningless.
- Connections are authenticated at the upgrade, per WebSocket Authentication & Authorization, because several protocol decisions — which errors are safe to expose, which close codes end a session — depend on knowing who is connected.
- You have picked an encoding. JSON is the default and is what the examples here use; the same designs apply to MessagePack or Protocol Buffers, covered under Message Framing & Serialization.
Three kinds of message #
Almost every real-time protocol, once its accidental variety is stripped away, consists of three kinds of frame. Requests travel from client to server and want an answer: rename this document, load this page of history, join this room. Replies travel back and answer exactly one request, successfully or with an error. Pushes travel from server to client unprompted: a new chat message, a price change, another user’s cursor. Pushes subdivide further into ordered streams, where every item matters and gaps must be detected, and ephemeral state, where the newest value supersedes all older ones.
Naming these kinds explicitly, in a discriminator field rather than by convention, is the single most useful protocol decision. It lets generic code handle each kind correctly without knowing any payload: the client resolves promises for replies and dispatches pushes to listeners; the server routes requests by type and never tries to route a reply; the resume layer tracks positions only for ordered pushes.
Core implementation #
The envelope below is the backbone every guide in this area builds on. It is a Zod discriminated union, so the server validates every inbound frame in one place and TypeScript narrows the type for each kind.
import { z } from 'zod';
import type { WebSocket } from 'ws';
export const PROTOCOL_VERSION = 3;
const MAX_FRAME_BYTES = 64 * 1024;
const Meta = z.object({ tc: z.record(z.string()).optional(), ts: z.number().int().optional() });
const WireError = z.object({
code: z.enum(['invalid', 'not_found', 'forbidden', 'conflict', 'rate_limited', 'unavailable', 'internal']),
message: z.string(),
retryable: z.boolean(),
details: z.record(z.unknown()).optional(),
});
export const Envelope = z.discriminatedUnion('kind', [
z.object({ v: z.literal(PROTOCOL_VERSION), kind: z.literal('req'), type: z.string(), id: z.string().max(40),
meta: Meta.optional(), data: z.unknown() }),
z.object({ v: z.literal(PROTOCOL_VERSION), kind: z.literal('res'), re: z.string(), ok: z.boolean(),
data: z.unknown().optional(), error: WireError.optional() }),
z.object({ v: z.literal(PROTOCOL_VERSION), kind: z.literal('push'), type: z.string(),
stream: z.string().optional(), seq: z.number().int().nonnegative().optional(),
meta: Meta.optional(), data: z.unknown() }),
]);
export type Envelope = z.infer<typeof Envelope>;
// Per-stream sequence counters live where messages for that stream are serialized.
const nextSeq = new Map<string, number>();
export function pushOrdered(sockets: Iterable<WebSocket>, stream: string, type: string, data: unknown) {
const seq = (nextSeq.get(stream) ?? 0) + 1;
nextSeq.set(stream, seq);
const frame = JSON.stringify({ v: PROTOCOL_VERSION, kind: 'push', type, stream, seq, data });
for (const ws of sockets) ws.send(frame); // serialize once for every recipient
return seq;
}
export function pushEphemeral(sockets: Iterable<WebSocket>, type: string, data: unknown) {
const frame = JSON.stringify({ v: PROTOCOL_VERSION, kind: 'push', type, data });
for (const ws of sockets) ws.send(frame); // no seq: a lost one is superseded
}
// Edge decoder: size limit, JSON, envelope — before anything else sees the frame.
export function decode(raw: Buffer): { ok: true; env: Envelope } | { ok: false; code: 'too_large' | 'bad_frame' } {
if (raw.length > MAX_FRAME_BYTES) return { ok: false, code: 'too_large' };
let parsed: unknown;
try { parsed = JSON.parse(raw.toString('utf8')); } catch { return { ok: false, code: 'bad_frame' }; }
const env = Envelope.safeParse(parsed);
return env.success ? { ok: true, env: env.data } : { ok: false, code: 'bad_frame' };
}
A note on where seq comes from: in a single-process server a counter per stream is enough, but on a multi-node fleet each node would count independently and the numbers would collide. Assign sequence numbers at the single point where a stream’s messages are serialized — the publisher, a Redis Stream entry id, or a database sequence — and carry them through fan-out unchanged. Ordering WebSocket messages with sequence numbers covers the multi-node version.
On top of this envelope sit a router that dispatches requests by type, described in building a WebSocket message router in TypeScript, and a client library that correlates replies with pending promises.
Designing for long-lived clients #
The property that most distinguishes WebSocket protocol design from REST API design is the lifetime of the client on the other end. A REST client makes a fresh request each time and picks up server changes immediately; a WebSocket client connects once and runs the same code for hours or days. Three consequences follow.
First, compatibility is continuous, not per request. Every push the server sends must be understood by every client version still connected. That makes additive change the default: add fields and message types freely, never rename or remove them without a negotiated version. Clients must be tolerant readers that ignore unknown fields and unknown types, and that property must be tested, because a single strict parser in the client turns every additive server change into an outage.
Second, state accumulates on both sides. Pending requests, subscriptions, stream positions and in-flight optimistic updates all live on the connection. When it drops, the protocol must say what happens to each: pending requests fail immediately, subscriptions are re-established, streams resume from their last sequence, optimistic updates are replayed or rolled back. Leaving any of these undefined produces the classic real-time bugs — spinners that never stop, duplicated messages, gaps nobody notices.
Third, errors must not be fatal by default. On HTTP, a failed request costs one request. On a WebSocket, closing the connection to report an error costs every subscription and every pending request on it. Error frames scoped to the request or stream that failed keep the blast radius small, and close codes are reserved for conditions that genuinely invalidate the connection.
Choosing a delivery style per message type #
Every new message type forces the same small set of decisions, and making them explicitly — in a table in the protocol documentation, reviewed like an API change — prevents most of the inconsistencies described at the top of this page.
Is it a request or a push? If the client initiates it and needs to know the outcome, it is a request and must receive a reply, even a failed one. If the server initiates it, it is a push. A client action whose outcome arrives as a broadcast to everyone (posting a chat message, for instance) is usually both: a request that gets a small acknowledgement reply, followed by the push that every member of the room receives, including the sender.
Ordered or ephemeral? Ask what happens if the client misses one. If the answer is “the view is wrong until the next full reload” — a missed chat message, a missed row insertion — the push belongs to an ordered stream with a sequence number, and a gap must trigger replay or resync. If the answer is “nothing, the next one corrects it” — a cursor position, a typing indicator, a price — it is ephemeral, carries no sequence, and should be coalesced on the server as described in coalescing high-frequency WebSocket updates.
Which stream? An ordered push belongs to exactly one ordering domain, normally the subscription it was delivered through. Choosing streams that are too broad couples unrelated data, so a gap in one forces a resync of everything; choosing streams that are too narrow multiplies the resume bookkeeping. One stream per room, document or feed is the usual balance.
Idempotent or not? Requests that may be retried after a reconnect need either natural idempotency (setting a title to a value) or an idempotency key the server deduplicates on. Decide per type, and document it next to the type’s schema so client authors know whether automatic retries are safe.
Who may send it? Every request type needs an authorization rule, even if the rule is “any authenticated user”. Recording it alongside the schema is what lets the router enforce it uniformly rather than leaving it to each handler.
Configuration reference #
| Parameter | Type | Default in examples | Production guidance |
|---|---|---|---|
PROTOCOL_VERSION |
integer | 3 |
Bump only for breaking changes; negotiate at the handshake |
MAX_FRAME_BYTES |
bytes | 65536 |
Largest legitimate message plus margin; also set maxPayload in ws |
| Request id format | string | counter r1, r2 |
Unique per connection; UUID if reused as an idempotency key |
| Request timeout | ms | 10000 |
Per request type; long operations send progress pushes |
| Max in-flight requests | count | 256 |
Bounds client memory and server work per connection |
| Sequence domain | string | per room or document | One stream per ordering domain, never per connection |
| Ephemeral types | list | cursor, typing, presence | No seq; coalesce on the server |
| Error codes | closed set | seven codes | Document; adding is a protocol change, removing is breaking |
| Violation limit | per minute | 20 |
Invalid frames before closing with an application code |
Edge cases & gotchas #
Sequence numbers assigned per node. The most common multi-node ordering bug: each server instance increments its own counter for the same room, so clients see duplicate or backwards sequence numbers depending on which node published. Assign sequence numbers once, where the stream is serialized.
Replies that arrive after the caller gave up. A request times out on the client, and its reply arrives a second later. If the pending entry was removed on timeout, the reply is dropped — correct — but the server-side effect happened. Prefer idempotent operations and follow up with a state push, so the UI converges even when a reply is lost.
Unknown message types from a newer server. A server deployed ahead of some clients starts pushing a new type. Clients with an exhaustive switch and a throw in the default branch crash; tolerant clients ignore it. Test tolerance explicitly.
Errors reported as closes. A handler that calls ws.close(1008) on a validation failure disconnects every subscription on the socket. Reserve closes for session, version and abuse conditions, and make everything else an error reply.
Verification #
Treat the protocol as an interface with tests of its own, separate from handler tests. Useful checks:
# Every outbound send goes through the envelope helpers — any hit here is a stray shape.
grep -rnE "\b(ws|socket|client)\.send\(" src/ | grep -v -E "sendEnvelope|pushOrdered|pushEphemeral"
# Inbound frames rejected at the edge, by reason, over the last hour.
jq -r 'select(.event=="ws.frame_rejected") | .code' app.log | sort | uniq -c
In the test suite, replay a recorded session from each supported client version against the current server and assert that every reply is well formed and every push parses under that version’s schema. In production, graph error replies by code, connections by protocol version, and the distribution of in-flight requests per connection. A protocol in good health shows near-zero edge rejections, a steadily decaying old-version share after each release, and in-flight counts in single digits.
Guides in this area #
- Designing a WebSocket Message Envelope — the shared outer structure: version, kind, type, correlation ids, sequence and metadata, with a field-by-field rationale.
- Request/Response Correlation over WebSockets — an awaitable
request()built on ids, a pending map, timeouts, cancellation and failing in-flight calls on disconnect. - Versioning WebSocket Message Schemas — additive evolution, version negotiation in the handshake, and edge up/downcasting for clients that stay connected for days.
- WebSocket Error Frames and Error Codes — a closed error-code set, the retryable flag, and when an error becomes a close.
FAQ #
Should I adopt an existing protocol instead of designing one? #
If one fits, yes. JSON-RPC 2.0 covers requests, replies and notifications; graphql-ws covers GraphQL subscriptions; STOMP and MQTT over WebSocket cover pub/sub. Designing your own makes sense when you need ordered, resumable streams alongside requests, which none of those provide in one shape. Either way, the concepts in this area are what you are choosing between.
JSON or binary for the protocol? #
Start with JSON: it is debuggable in every browser’s DevTools and fast enough for most workloads. Move to a binary encoding when measured bandwidth or parse time justifies it, keeping the same envelope fields. Protobuf over WebSockets shows the envelope in binary form.
Do I need sequence numbers if TCP already guarantees order? #
TCP orders bytes on one connection. It cannot tell you what you missed while disconnected, and it cannot order messages that reached different server nodes before being fanned out. Sequence numbers make gaps detectable and resumption possible.
How do Socket.IO events map onto this design? #
Socket.IO events are pushes or requests with a type (the event name); acknowledgements are replies correlated by an internal id. It lacks sequence numbers and a standard error shape, so you add those on top if you need them.
Related #
- Server-Side Routing Patterns — dispatching requests once the envelope is decoded.
- Message Delivery Guarantees — acknowledgements and replay built on sequence numbers.
- Message Framing & Serialization — encoding the envelope on the wire.
- WebSocket Close Codes Explained — the connection-ending side of error handling.