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.

Anatomy of an envelope An envelope with protocol version, message type, correlation ids, sequence number, cross-cutting metadata and a type-specific payload. Anatomy of an envelope v — protocol version integer; lets both sides refuse or adapt early required type — what this is dotted name: chat.send, cursor.move, error required id / re — correlation id on requests; re on replies names the request optional seq — stream position per-stream monotonic number for ordering and resume on pushes meta — cross-cutting trace context, timestamps, signatures optional data — the payload type-specific body, validated per type required Infrastructure reads the top five fields; only handlers read data
Everything generic lives outside data; everything specific lives inside it.

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.

Envelope overhead in context Wire bytes per update for payloads of 32, 128, 512, 2048 bytes: a WebSocket frame adds two to four bytes, an SSE event eight, and a polling round trip repeats 420 bytes of headers. Envelope overhead in context frame/protocol overhead only — polling assumes 420 B of HTTP headers WebSocket frame SSE event HTTP poll 0 B 1.0k B 2.0k B 3.0k B 32 B 128 B 512 B 2048 B
A 60-byte envelope is significant only for the tiniest payloads; for anything over a few hundred bytes it disappears next to the payload — and it is a fraction of an HTTP request's headers.

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.

Which fields each kind carries Which envelope fields appear on requests, replies and pushes: version and kind always, type on requests and pushes, id on requests and re on replies, stream and seq on ordered pushes, error on failed replies. Which fields each kind carries Request Reply Push v, kind yes yes yes type yes implied by re yes id / re id re stream, seq if ordered error when ok=false The discriminated union makes invalid combinations unrepresentable
Three kinds, each with only the fields it needs.

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.

Back to WebSocket Message Protocol Design.