Validating WebSocket Messages with Zod #

The handler reads msg.payload.roomId and the process crashes because payload was a string. Or it does not crash — it writes undefined into a Redis key and quietly corrupts a room. Every message arriving on a WebSocket is attacker-controlled input from an authenticated client, and unlike an HTTP endpoint there is no framework in front of it doing body parsing, content-type checks or schema validation. Whatever your onmessage handler assumes about shape is an assumption a client can violate.

Zod fixes this properly rather than defensively: instead of scattering if (typeof x !== 'string') through handlers, you parse once at the boundary and everything downstream is typed.

Root cause #

JSON.parse returns any. TypeScript will happily let you write msg.d.roomId.trim() on it, and the compiler is not lying to you — it simply has no information. The type you wrote in the interface describes what you hope arrives, not what does.

Three failure classes follow, in increasing order of cost:

Crashes. A property access on undefined inside an event handler throws, and an unhandled exception in an onmessage handler can take down the process or, worse, kill only that handler and leave the socket silently inert.

Corruption. A wrong-typed value passes through validation-free code and lands in state — a number where a string was expected, an object where an id was expected — and now the bug is in your data rather than your logs.

Amplification. A message with a huge nested array reaches a fan-out loop before anyone checks its size, and one client turns 4 KB into 40 MB of outbound traffic.

The last one is why validation order matters: check the byte length before you parse, check the shape before you route, and route before you do anything expensive.

Validation order on the inbound path Inbound messages pass through six ordered stages from cheapest to most expensive: byte length, JSON parse, envelope schema, per-type schema, authorisation, then the handler itself. Validation order on the inbound path 1. Byte length reject over maxPayload before any parsing work cheapest 2. JSON parse in try/catch malformed input is a drop, never a throw 3. Envelope schema version, type, sequence — the transport contract 4. Discriminated union by type one schema per message type, exhaustively checked 5. Authorisation scopes for THIS message type on THIS connection 6. Handler receives a fully typed, authorised value expensive Every stage rejects before the next one spends anything — that ordering is the whole defence
Cost rises down the stack, so each check pays for itself by preventing the ones below it.

Resolution #

One discriminated union describes every message the server accepts. The router is then exhaustive by construction: adding a message type without a handler is a compile error, not a runtime surprise.

import { z } from 'zod';
import { WebSocket } from 'ws';

const MAX_MESSAGE_BYTES = 64 * 1024;

// Each message type gets its own schema; the `t` field discriminates.
const JoinRoom = z.object({
t: z.literal('room.join'),
d: z.object({
roomId: z.string().uuid(),
since: z.number().int().nonnegative().optional(),
}),
});

const PostMessage = z.object({
t: z.literal('room.post'),
d: z.object({
roomId: z.string().uuid(),
body: z.string().min(1).max(4000),
// Idempotency key: the client generates it so a retry is deduplicated.
clientMsgId: z.string().uuid(),
}),
});

const Typing = z.object({
t: z.literal('room.typing'),
d: z.object({ roomId: z.string().uuid(), isTyping: z.boolean() }),
});

const Envelope = z.object({
v: z.number().int().min(1).max(2),
s: z.number().int().nonnegative(),
}).and(z.discriminatedUnion('t', [JoinRoom, PostMessage, Typing]));

export type Inbound = z.infer<typeof Envelope>;

const SCOPES: Record<Inbound['t'], string> = {
'room.join': 'room:read',
'room.post': 'room:write',
'room.typing': 'room:write',
};

export function handleRaw(socket: WebSocket, raw: Buffer, scopes: Set<string>): void {
// 1. Size first: rejecting here costs nothing, parsing a 40 MB body costs everything.
if (raw.byteLength > MAX_MESSAGE_BYTES) {
return reject(socket, 'too_large');
}

// 2. Malformed JSON is an expected condition on a public endpoint, not an error.
let parsed: unknown;
try { parsed = JSON.parse(raw.toString('utf8')); } catch { return reject(socket, 'malformed'); }

// 3+4. One parse gives a fully typed value or a structured failure.
const result = Envelope.safeParse(parsed);
if (!result.success) {
// Send the client the FIELD PATHS that failed, never the raw Zod message,
// which can echo input back and leak internals into logs and screenshots.
return reject(socket, 'invalid', result.error.issues.map((i) => i.path.join('.')));
}
const msg = result.data;

// 5. Authorise the specific message type, reading live scopes rather than any
// captured at connection time — tokens rotate while the socket stays open.
if (!scopes.has(SCOPES[msg.t])) return reject(socket, 'forbidden');

// 6. Exhaustive routing: adding a variant without a case fails to compile.
switch (msg.t) {
case 'room.join': return onJoin(socket, msg.d);
case 'room.post': return onPost(socket, msg.d);
case 'room.typing': return onTyping(socket, msg.d);
default: {
const _never: never = msg;
return reject(socket, 'unknown_type');
}
}
}

function reject(socket: WebSocket, reason: string, fields?: string[]): void {
if (socket.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify({ t: 'error', reason, fields }));
}
}

The const _never: never = msg line in the default branch is what makes the router exhaustive. Add a fourth message type to the union without adding a case and TypeScript refuses to compile, which is a far better guard than a test you might forget to write.

Note what the error frame does not contain: the offending value, the Zod message, or a stack. Field paths are enough for a client developer to fix the bug and carry no risk of echoing a token or a password that was mistakenly sent in the wrong field.

A malformed message never reaches the handler An invalid message is rejected by the validator with the failing field paths and never reaches the handler; a valid one arrives at the handler fully typed and already authorised. A malformed message never reaches the handler Client Validator Handler { t: 'room.post', d: { body: 42 } } safeParse — body is not a string error frame: fields ['d.body'] { t: 'room.post', d: { body: 'hi' } } typed value, scopes checked ack + fan-out The handler has no validation code in it at all — that is the point of parsing at the boundary
Handlers stay clean because every shape question was answered one layer earlier.

Verification #

Test the rejections as deliberately as the happy path — they are the part that protects you.

# A wrong-typed field must produce an error frame, not a crash
npx wscat -c ws://localhost:8080/ws -x '{"v":1,"s":1,"t":"room.post","d":{"body":42}}'

# An unknown type must be rejected without touching any handler
npx wscat -c ws://localhost:8080/ws -x '{"v":1,"s":1,"t":"room.delete_everything","d":{}}'

# Oversized payloads must be dropped before parse — watch CPU stay flat
head -c 200000 /dev/zero | tr '\0' 'a' | xargs -0 -I{} npx wscat -c ws://localhost:8080/ws -x '{}'

Then assert on the metric: ws_messages_rejected_total labelled by reason should be non-zero in production and dominated by malformed (scanners and stray proxies) rather than invalid (a real client bug). A rising invalid rate after a deploy is the fastest possible signal that a client and server disagree about the schema.

Where the time goes on one inbound message Validation stages cost tens of microseconds combined while the handler and fan-out cost hundreds, so rejecting early is far cheaper than rejecting late. Where the time goes on one inbound message 4 KB payload, 200-socket room — relative cost per stage Validation µs Handler µs 0 µs 100 µs 200 µs 300 µs 400 µs Size check JSON.parse Zod safeParse 340 µs Handler + fan-out
Validation is roughly a tenth of the cost of handling the message — and it is the only thing standing between a bad frame and the fan-out loop.

Operational checklist #

FAQ #

Does Zod add meaningful latency per message? #

Tens of microseconds for a typical envelope, against hundreds for the handling and fan-out that follow — so it is roughly a tenth of the work you were going to do anyway. If a specific hot path genuinely needs more, compile the schema once at module scope (never inside the handler), avoid .refine() chains on the hot path, and consider a faster validator with the same discriminated-union shape. Measure before optimising: in practice serialisation, not validation, is the CPU cost that matters.

Should the client validate too? #

Validate inbound messages on the client, yes — a server bug or a version skew can send a shape the UI does not expect, and a crash in a message handler freezes the whole real-time view. Share the schema module between client and server if they are in one repository; if not, generate both from the same definition rather than maintaining two by hand, which is exactly how they drift.

How do I evolve a schema without breaking older clients? #

Additively. New fields must be .optional(), new message types must be safely ignorable by clients that do not know them, and removals wait a full client-refresh cycle. Bump the envelope v only for incompatible changes to the envelope itself, and accept both versions in parallel during migration — the versioning discipline described in message framing and serialization.

What should the server do with an unknown message type? #

Reject it with a structured error and count it, but do not close the connection. An unknown type usually means a newer client talking to an older server during a rolling deploy, which is a transient condition that resolves itself; closing turns a harmless mismatch into a reconnect storm. Reserve closing for repeated policy violations, as covered in rate limiting WebSocket messages per client.

Does this work with binary payloads? #

Yes — decode first, then validate the resulting object with the same schema. The only change is step two: replace JSON.parse with your MessagePack or Protobuf decode, keep it inside the same try/catch, and everything downstream is identical. Zod validates the decoded value, not the wire format.

Back to Server-Side Routing Patterns