Designing a WebSocket message envelope #
Look at the frames your application sends today. Some are {"event":"chat","text":"hi"}, some are {"t":"cursor","x":10}, one legacy path sends a bare array, errors arrive as {"error":"nope"} with no indication of which request failed, and nobody can resume a stream after reconnecting because nothing carries a sequence number. Every feature added a message shape, and each shape solves its own problem differently. A message envelope — a fixed outer structure that every frame shares, with the variable part inside it — is the one design decision that makes routing, request/response correlation, ordering, replay, tracing and versioning uniform instead of bespoke.
Root cause #
WebSocket gives you a stream of frames and nothing else: no methods, no status codes, no headers after the handshake, no request ids. HTTP applications inherit all of those from the protocol; WebSocket applications must invent them. When there is no deliberate envelope, each concern gets bolted onto individual messages as it is discovered. Correlation is added to the three messages that needed it first, sequence numbers to one stream, a version field to whichever message broke last. The resulting protocol cannot be handled generically: a router must know every shape, a replay buffer must know which messages carry sequence numbers, and a client cannot tell a reply from a push.
The cost shows up when you try to build infrastructure — a reconnect-and-resume layer, a tracing propagator, a rate limiter by message kind — and discover that each one needs a special case for every message type.
Resolution #
Define the envelope as a discriminated union in TypeScript and validate it at the edge. Three kinds of frame cover almost every real-time protocol: requests (client asks, expects a reply), replies (answer a specific request, success or error) and pushes (server-initiated, often part of an ordered stream). Encoding the kind in the shape, rather than in convention, lets the compiler and the router treat each correctly.
import { z } from 'zod';
export const PROTOCOL_VERSION = 3;
const Meta = z.object({
tc: z.record(z.string()).optional(), // W3C trace context carrier
ts: z.number().int().optional(), // sender timestamp, ms
}).strict();
// Client -> server: something that wants an answer.
const Request = z.object({
v: z.literal(PROTOCOL_VERSION),
kind: z.literal('req'),
type: z.string().regex(/^[a-z]+(\.[a-z_]+)+$/), // namespaced: doc.rename
id: z.string().min(1).max(40), // unique per connection
meta: Meta.optional(),
data: z.unknown(),
});
// Server -> client: answer to exactly one request.
const Reply = z.object({
v: z.literal(PROTOCOL_VERSION),
kind: z.literal('res'),
re: z.string(), // the request id being answered
ok: z.boolean(),
data: z.unknown().optional(), // present when ok
error: z.object({ code: z.string(), message: z.string(), retryable: z.boolean() }).optional(),
});
// Server -> client: unsolicited, possibly ordered.
const Push = z.object({
v: z.literal(PROTOCOL_VERSION),
kind: z.literal('push'),
type: z.string(),
stream: z.string().optional(), // e.g. room:42 — the ordering domain
seq: z.number().int().nonnegative().optional(), // monotonic within stream
meta: Meta.optional(),
data: z.unknown(),
});
export const Envelope = z.discriminatedUnion('kind', [Request, Reply, Push]);
export type Envelope = z.infer<typeof Envelope>;
// A single constructor per kind keeps every producer consistent.
export const push = (type: string, data: unknown, stream?: string, seq?: number): Envelope =>
({ v: PROTOCOL_VERSION, kind: 'push', type, data, stream, seq, meta: { ts: Date.now() } });
export const reply = (re: string, data: unknown): Envelope =>
({ v: PROTOCOL_VERSION, kind: 'res', re, ok: true, data });
export const fail = (re: string, code: string, message: string, retryable = false): Envelope =>
({ v: PROTOCOL_VERSION, kind: 'res', re, ok: false, error: { code, message, retryable } });
With this shape, generic machinery becomes simple. The router dispatches on type for requests and never needs to understand pushes, as in building a WebSocket message router in TypeScript. The client matches replies to pending promises by re — the pattern in request/response correlation over WebSockets. The resume layer reads stream and seq from pushes without knowing any payload. And the tracing propagator writes into meta.tc for every message the same way.
Keep field names short if bandwidth matters — v, kind, re are deliberate — but do not go further and invent single-byte keys for everything. The savings are small next to permessage-deflate or a binary encoding, and unreadable frames make every debugging session slower.
Choosing sequence domains #
seq is only meaningful within a stream: the domain inside which order matters and gaps can be detected. Making the whole connection one stream is simplest but couples unrelated data — a gap in cursor positions would force a resync of chat history. Making every entity its own stream multiplies the bookkeeping. The usual answer is one stream per subscription (a room, a document, a feed), with the server assigning seq at the point where messages for that stream are serialized — typically the publisher or a Redis Stream id. The client keeps lastSeq per stream and resumes each independently, as described in ordering WebSocket messages with sequence numbers.
Not every push needs a sequence. Ephemeral state such as cursor positions or typing indicators is better without one: a lost cursor update is superseded by the next, and resyncing on a gap would cost more than it saves.
Verification #
Validate every inbound frame against Envelope at the edge and count failures by reason; a protocol with a real envelope should see almost none. For outbound frames, add a development-mode assertion that runs Envelope.parse on everything before send, so a producer that bypasses the constructors fails loudly in tests rather than confusing clients in production:
const assertOutbound = process.env.NODE_ENV !== 'production';
export function sendEnvelope(ws: import('ws').WebSocket, env: Envelope) {
if (assertOutbound) Envelope.parse(env); // throws with a precise path on violations
ws.send(JSON.stringify(env));
}
A useful audit is to grep the codebase for ws.send( and socket.send( calls that do not go through sendEnvelope; each one is a message shape that escapes the protocol.
Operational checklist #
FAQ #
Should I use JSON-RPC 2.0 instead of a custom envelope? #
JSON-RPC covers requests, replies and notifications with a standard shape, and it is a reasonable choice. It has no notion of streams or sequence numbers, so you will still add fields for ordered pushes. A custom envelope modelled on the same ideas is equally valid; what matters is having one.
Where does the message type go — in the envelope or the payload? #
In the envelope. Routing, metrics and authorization all key on the type, and they should not need to parse the payload to find it.
How do I migrate an existing protocol to an envelope? #
Bump the protocol version, have the server accept both old shapes and the envelope for a transition period, and convert old shapes into envelopes at the edge so internal code sees only one form. Versioning strategy is covered in versioning WebSocket message schemas.
Is the envelope worth it for binary protocols? #
Yes — the fields are the same, only the encoding changes. With MessagePack or Protocol Buffers the envelope becomes an outer message with the payload as a nested message or bytes field.
Related #
- Request/Response Correlation over WebSockets — using
idandre. - Versioning WebSocket Message Schemas — evolving
vand payloads. - WebSocket Error Frames and Error Codes — the error shape in replies.
- Validating WebSocket Messages with Zod — enforcing the envelope at the edge.
Back to WebSocket Message Protocol Design.