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.

Where the application protocol sits A four-layer stack with application handlers on top, the application protocol designed in this area beneath them, then WebSocket framing and TLS over TCP. Where the application protocol sits Application handlers business logic: rename a document, post a message your code Application protocol envelope, correlation, sequencing, errors, versions this area WebSocket framing RFC 6455: text/binary frames, ping/pong, close codes ws library TLS + TCP encryption, reliable in-order bytes kernel RFC 6455 stops at frames; everything above it is yours to design
The protocol layer is the part of a real-time system that no library ships for you.

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.

The three kinds in one session A client joins a room with a request and receives a reply containing a snapshot, then receives an ordered chat push with a sequence number and an ephemeral cursor push without one, and finally a request that fails with a rate-limited error reply. The three kinds in one session Client Server Other users req id=r1 room.join res re=r1 ok (snapshot) someone posts push chat.message seq=41 push cursor.move (no seq) req id=r2 chat.send res re=r2 error rate_limited Replies carry re, ordered pushes carry seq, ephemeral pushes carry neither
Requests, replies and pushes — each with the fields its handling needs.

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.

What the protocol must define across a reconnect When a connection drops, pending requests are rejected immediately; after reconnecting and re-authenticating the client resubscribes, resumes each stream from its last sequence and replays unacknowledged optimistic operations. What the protocol must define across a reconnect connection drops (0 s) pending requests rejected (0.1 s) reconnect + re-auth (1.5 s) resubscribe streams (1.8 s) resume from last seq (2 s) replay optimistic ops (2.3 s) Each mark is a protocol rule; leave one undefined and it becomes a bug
A protocol is complete only when every piece of connection state has a reconnect rule.

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 #

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.

Back to Backend WebSocket Connection Management.