JSON WebSocket Messages: ws, Socket.IO, AJV Validation & Reconnection

Last updated:

WebSocket JSON messages require a consistent framing convention — a type discriminator field routes messages to the correct handler without a fragile switch-on-index approach. The ws library for Node.js sends and receives raw strings; JSON.stringify() and JSON.parse() are required for every message — unlike HTTP where frameworks handle serialization automatically. Socket.IO wraps ws and handles JSON encoding automatically but adds approximately 30 KB to the client bundle.

This guide covers the ws library setup, message framing with discriminated unions, AJV schema validation of incoming messages, exponential backoff reconnection, binary vs JSON frame choice, and Socket.IO integration. All examples include TypeScript types. For related patterns, see our guides on JSON streaming and JSON API design.

WebSocket JSON Message Framing with Type Discriminators

A type discriminator is a type string field present in every WebSocket JSON message that tells the receiver which handler to invoke. Without it, receivers must infer message kind from shape — a pattern that breaks as soon as two message types share overlapping fields. The discriminated union pattern maps directly to TypeScript's narrowing system: a switch (msg.type) gives full type safety inside each case branch without explicit casts.

// ── Message envelope: every message has a type field ─────────────
// Client → Server messages
type PingMessage      = { type: "ping" }
type ChatMessage      = { type: "chat.message";  payload: { text: string; roomId: string } }
type SubscribeMessage = { type: "subscribe";     payload: { channel: string } }

// Server → Client messages
type PongMessage      = { type: "pong";          ts: number }
type ChatBroadcast    = { type: "chat.broadcast"; payload: { text: string; from: string; roomId: string } }
type ErrorMessage     = { type: "error";         code: number; message: string }

type ClientMessage = PingMessage | ChatMessage | SubscribeMessage
type ServerMessage = PongMessage | ChatBroadcast | ErrorMessage

// ── Dispatcher pattern — handler map avoids fragile switch-on-index
type Handler<T> = (payload: T, ws: WebSocket) => void

const handlers: Record<string, Handler<ClientMessage>> = {
  "ping": (_msg, ws) => {
    ws.send(JSON.stringify({ type: "pong", ts: Date.now() } satisfies PongMessage))
  },
  "chat.message": (msg, ws) => {
    const m = msg as ChatMessage
    const broadcast: ChatBroadcast = {
      type: "chat.broadcast",
      payload: { text: m.payload.text, from: "user-1", roomId: m.payload.roomId },
    }
    ws.send(JSON.stringify(broadcast))
  },
  "subscribe": (msg, _ws) => {
    const m = msg as SubscribeMessage
    console.log("Subscribe to channel:", m.payload.channel)
  },
}

function dispatch(raw: string, ws: WebSocket): void {
  let msg: ClientMessage
  try {
    msg = JSON.parse(raw) as ClientMessage
  } catch {
    ws.send(JSON.stringify({ type: "error", code: 1003, message: "Invalid JSON" }))
    return
  }

  const handler = handlers[msg.type]
  if (!handler) {
    ws.send(JSON.stringify({ type: "error", code: 1004, message: `Unknown type: ${msg.type}` }))
    return
  }
  handler(msg, ws)
}

// ── Optional envelope fields ──────────────────────────────────────
// id field for request/response correlation (echoed back in response)
type RequestEnvelope = {
  id: string
  type: string
  payload?: unknown
}

// v field for protocol versioning
type VersionedEnvelope = {
  v: 1
  type: string
  payload?: unknown
}

The handler map pattern (handlers[msg.type]) scales better than a switch statement — adding a new message type means adding one key to the map, not inserting a new case block. The optional id field enables request/response correlation over the same WebSocket connection: the server echoes the id back in its reply, and the client resolves a pending Promise keyed by that ID. This turns WebSocket into a request/response protocol when needed, without giving up the ability to push unsolicited server events. The v (version) field allows protocol upgrades without breaking existing clients — clients that do not understand a newer message type can check the version and fall back gracefully.

Setting Up the ws Library for JSON WebSocket Communication

The ws npm package is the de-facto WebSocket server for Node.js. It transmits raw strings and Buffers — JSON serialization is your responsibility on every send and every receive. Install with npm install ws and npm install --save-dev @types/ws for TypeScript. The server creates a WebSocketServer; each connected client gets its own WebSocket instance in the connection event handler.

// ── Server setup (Node.js + TypeScript) ──────────────────────────
import { WebSocketServer, WebSocket } from "ws"
import type { IncomingMessage } from "http"

const wss = new WebSocketServer({ port: 8080 })

wss.on("listening", () => console.log("WebSocket server on ws://localhost:8080"))

wss.on("connection", (ws: WebSocket, req: IncomingMessage) => {
  console.log("Client connected from", req.socket.remoteAddress)

  // Send a welcome message immediately on connection
  ws.send(JSON.stringify({ type: "welcome", ts: Date.now() }))

  ws.on("message", (data: Buffer | string) => {
    // ws delivers messages as Buffer — normalise to string first
    const raw = typeof data === "string" ? data : data.toString("utf-8")
    let msg: unknown
    try {
      msg = JSON.parse(raw)
    } catch {
      ws.send(JSON.stringify({ type: "error", code: 1003, message: "Invalid JSON" }))
      return
    }
    // Dispatch using the type discriminator handler map
    dispatch(JSON.stringify(msg), ws)
  })

  ws.on("close", (code: number, reason: Buffer) => {
    console.log(`Client disconnected: ${code} ${reason.toString()}`)
  })

  ws.on("error", (err: Error) => {
    console.error("WebSocket error:", err.message)
  })

  // Heartbeat: detect dead connections every 30 seconds
  const heartbeat = setInterval(() => {
    if (ws.readyState === WebSocket.OPEN) ws.ping()
  }, 30_000)
  ws.on("close", () => clearInterval(heartbeat))
})

// ── Browser client (native WebSocket API) ─────────────────────────
const socket = new WebSocket("ws://localhost:8080")

socket.addEventListener("open", () => {
  console.log("Connected")
  // JSON.stringify required — WebSocket API does not serialize objects
  socket.send(JSON.stringify({ type: "ping" }))
})

socket.addEventListener("message", (event: MessageEvent<string>) => {
  // JSON.parse required — event.data is always a string for text frames
  const msg = JSON.parse(event.data) as ServerMessage
  console.log("Received:", msg.type)
})

socket.addEventListener("close", (event) => {
  console.log("Disconnected:", event.code, event.reason)
})

// ── Helper: type-safe send ────────────────────────────────────────
function sendMessage(ws: WebSocket, msg: ServerMessage): void {
  if (ws.readyState !== WebSocket.OPEN) return
  ws.send(JSON.stringify(msg))
}

// ── ws acting as a Node.js client ────────────────────────────────
import WebSocket from "ws"

const client = new WebSocket("ws://localhost:8080")
client.on("open", () => client.send(JSON.stringify({ type: "ping" })))
client.on("message", (data) => {
  const msg = JSON.parse(data.toString()) as ServerMessage
  console.log(msg)
})

The heartbeat pattern (30-second ping interval) is essential in production — load balancers and firewalls silently close idle TCP connections, and without a heartbeat the server accumulates zombie connections whose close event never fires. The ws library sends a WebSocket ping frame; browsers respond with a pong frame automatically. For high-throughput scenarios, serialise the JSON payload once and reuse the resulting string when sending the same message to multiple clients. See our JSON performance guide for serialisation benchmarks and optimisation techniques.

Validating Incoming JSON Messages with AJV

AJV (Another JSON Validator) compiles JSON Schema into optimised JavaScript validation functions that run in under 0.1 ms per message. Compile schemas once at server startup — compilation is expensive and must not happen in the message handler hot path. Never trust incoming WebSocket message structure: validate the type field, all required payload fields, and value ranges before passing to business logic. Invalid messages get a structured error response, not a silent drop.

import Ajv, { JSONSchemaType, ValidateFunction } from "ajv"
import addFormats from "ajv-formats"   // npm install ajv-formats

// allErrors: true collects all errors, not just the first
const ajv = new Ajv({ allErrors: true })
addFormats(ajv)                        // adds "email", "date-time", "uuid" formats

// ── Define schemas for each incoming message type ─────────────────
interface ChatPayload { text: string; roomId: string }

const chatMessageSchema: JSONSchemaType<{ type: "chat.message"; payload: ChatPayload }> = {
  type: "object",
  properties: {
    type:    { type: "string", const: "chat.message" },
    payload: {
      type: "object",
      properties: {
        text:   { type: "string", minLength: 1, maxLength: 2000 },
        roomId: { type: "string", format: "uuid" },
      },
      required: ["text", "roomId"],
      additionalProperties: false,   // reject unexpected fields
    },
  },
  required: ["type", "payload"],
  additionalProperties: false,
}

interface SubscribePayload { channel: string }

const subscribeSchema: JSONSchemaType<{ type: "subscribe"; payload: SubscribePayload }> = {
  type: "object",
  properties: {
    type:    { type: "string", const: "subscribe" },
    payload: {
      type: "object",
      properties: {
        channel: { type: "string", pattern: "^[a-z0-9-]+$", maxLength: 64 },
      },
      required: ["channel"],
      additionalProperties: false,
    },
  },
  required: ["type", "payload"],
  additionalProperties: false,
}

// ── Compile validators ONCE at startup ────────────────────────────
const validators: Record<string, ValidateFunction> = {
  "chat.message": ajv.compile(chatMessageSchema),
  "subscribe":    ajv.compile(subscribeSchema),
}

// ── Validate and dispatch ─────────────────────────────────────────
import { WebSocket } from "ws"

function handleMessage(raw: string, ws: WebSocket): void {
  // Step 1: parse JSON
  let parsed: unknown
  try {
    parsed = JSON.parse(raw)
  } catch {
    ws.send(JSON.stringify({ type: "error", code: 1003, message: "Invalid JSON" }))
    return
  }

  // Step 2: check type field exists and is a string
  if (
    typeof parsed !== "object" || parsed === null ||
    !("type" in parsed) ||
    typeof (parsed as Record<string, unknown>).type !== "string"
  ) {
    ws.send(JSON.stringify({ type: "error", code: 1004, message: "Missing or invalid type field" }))
    return
  }

  const { type } = parsed as { type: string }

  // Step 3: look up and run the validator for this type
  const validate = validators[type]
  if (!validate) {
    ws.send(JSON.stringify({ type: "error", code: 1005, message: `Unknown message type: ${type}` }))
    return
  }

  if (!validate(parsed)) {
    ws.send(JSON.stringify({
      type:    "error",
      code:    1006,
      message: "Schema validation failed",
      errors:  validate.errors,   // AJV error objects with field paths and messages
    }))
    return
  }

  // Step 4: dispatch — parsed is now validated and safe to use
  dispatch(JSON.stringify(parsed), ws)
}

// ── AJV standalone compilation (removes runtime from hot path) ────
// npx ajv compile -s chat-message-schema.json -o validators/chat-message.js
// import validateChatMessage from "./validators/chat-message.js"

Use additionalProperties: false in every schema — it rejects messages with unexpected fields, preventing attackers from smuggling extra data through the message envelope. The allErrors: true option collects all validation errors in one pass, giving client developers a complete picture of what is malformed rather than requiring multiple round trips to fix errors one at a time. For production deployments with high message throughput, pre-compile validators with npx ajv compile to eliminate the AJV runtime from the hot path entirely. See our JSON security guide for additional input validation strategies including prototype pollution prevention and size limits.

Reconnection Strategies: Exponential Backoff for JSON WebSockets

WebSocket connections drop due to network interruptions, server restarts, and load balancer idle timeouts. A robust client reconnects automatically with exponential backoff — starting at 1 second, doubling each attempt, capping at 30 seconds — plus random jitter to prevent thundering herd. After reconnection, send a state recovery message immediately so the server can resume streaming from the last known sequence number.

// ── Exponential backoff reconnection (browser client) ────────────
interface ReconnectOptions {
  url: string
  minDelay?: number    // ms, default 1000
  maxDelay?: number    // ms, default 30000
  jitter?: number      // fraction 0–1, default 0.2
  onMessage: (msg: ServerMessage) => void
  onOpen?: () => void
}

function createReconnectingWebSocket(opts: ReconnectOptions) {
  const { url, minDelay = 1000, maxDelay = 30_000, jitter = 0.2 } = opts
  let ws: WebSocket | null = null
  let attempt = 0
  let reconnectTimer: ReturnType<typeof setTimeout> | null = null
  let destroyed = false
  let lastSeq = 0        // last sequence number received — for state recovery

  function connect(): void {
    ws = new WebSocket(url)

    ws.addEventListener("open", () => {
      attempt = 0         // reset backoff counter on successful connection
      console.log("WebSocket connected")
      // Send state recovery message immediately after open
      ws!.send(JSON.stringify({ type: "resume", payload: { lastSeq } }))
      opts.onOpen?.()
    })

    ws.addEventListener("message", (event: MessageEvent<string>) => {
      const msg = JSON.parse(event.data) as ServerMessage
      // Track seq for recovery after reconnection
      if ("seq" in msg && typeof (msg as Record<string, unknown>).seq === "number") {
        lastSeq = (msg as Record<string, unknown>).seq as number
      }
      opts.onMessage(msg)
    })

    ws.addEventListener("close", (event) => {
      if (destroyed) return
      // Do not reconnect after intentional server-initiated close (1000 Normal Closure)
      if (event.wasClean && event.code === 1000) return
      scheduleReconnect()
    })

    ws.addEventListener("error", () => {
      // error always precedes close — reconnection handled in onclose
    })
  }

  function scheduleReconnect(): void {
    // delay = min(minDelay × 2^attempt, maxDelay) × (1 ± jitter)
    const base = Math.min(minDelay * 2 ** attempt, maxDelay)
    const jitterFactor = 1 - jitter + Math.random() * jitter * 2
    const delay = Math.round(base * jitterFactor)
    attempt++
    console.log(`Reconnecting in ${delay}ms (attempt ${attempt})`)
    reconnectTimer = setTimeout(connect, delay)
  }

  // Backoff schedule (minDelay=1000, maxDelay=30000, jitter=0.2):
  // Attempt 1:  ~1 000 ms   Attempt 3:  ~4 000 ms   Attempt 5:  ~16 000 ms
  // Attempt 2:  ~2 000 ms   Attempt 4:  ~8 000 ms   Attempt 6+: ~30 000 ms

  function send(msg: ClientMessage): void {
    if (ws?.readyState === WebSocket.OPEN) {
      ws.send(JSON.stringify(msg))
    } else {
      console.warn("WebSocket not open — message dropped:", msg.type)
    }
  }

  function destroy(): void {
    destroyed = true
    if (reconnectTimer) clearTimeout(reconnectTimer)
    ws?.close(1000, "Client destroyed")
  }

  connect()
  return { send, destroy }
}

// ── Usage ─────────────────────────────────────────────────────────
const client = createReconnectingWebSocket({
  url: "wss://api.example.com/ws",
  onMessage: (msg) => console.log("Message:", msg),
  onOpen: () => client.send({ type: "subscribe", payload: { channel: "prices" } }),
})

// ── Server: handle resume messages ────────────────────────────────
// ws.on("message", (raw) => {
//   const msg = JSON.parse(raw.toString())
//   if (msg.type === "resume") {
//     replayMissedEvents(ws, msg.payload.lastSeq)
//   }
// })

The wasClean && code === 1000 check prevents reconnection after intentional server-initiated closes — without it, a graceful server shutdown would trigger runaway reconnection attempts against the new server instance. Always clear the pending reconnect timer before creating a new connection to prevent duplicate connections when the timer fires concurrently with a manual reconnect. For applications that must not lose messages during disconnection, buffer outbound messages in an array during the closed window and flush the queue in the onopen handler after the recovery message is confirmed. The jitter fraction (±20%) is sufficient to desynchronise large numbers of clients without adding so much variance that the delay becomes unpredictable.

Binary vs JSON Frames: When to Use MessagePack or CBOR

JSON is the correct default for WebSocket messages — human-readable in DevTools, universally supported, and trivial to debug. Switch to a binary format only when profiling confirms JSON serialisation is a measurable bottleneck: typically more than 100 messages per second per connection, or messages containing large numeric arrays. JSON adds 5–20% overhead versus MessagePack for typical data; the gap reaches 40–60% for numeric-heavy payloads like game state or sensor readings.

// ── Install: npm install @msgpack/msgpack ─────────────────────────
import { encode, decode } from "@msgpack/msgpack"
import { WebSocketServer, WebSocket } from "ws"

// ── Server: handle both JSON (text) and MessagePack (binary) ──────
wss.on("connection", (ws) => {
  let useBinary = false    // default: JSON text frames

  ws.on("message", (data: Buffer | string, isBinary: boolean) => {
    let msg: unknown

    if (isBinary) {
      // Binary frame — decode as MessagePack
      msg = decode(data as Buffer)
    } else {
      // Text frame — decode as JSON
      try {
        msg = JSON.parse((data as Buffer).toString())
      } catch {
        ws.send(JSON.stringify({ type: "error", message: "Invalid JSON" }))
        return
      }
    }

    // Encoding negotiation handshake
    if (
      typeof msg === "object" && msg !== null &&
      (msg as Record<string, unknown>).type === "negotiate" &&
      (msg as Record<string, unknown>).encoding === "msgpack"
    ) {
      useBinary = true
      sendMsg(ws, { type: "negotiate.ack", encoding: "msgpack" }, useBinary)
      return
    }

    const response = processMessage(msg)
    sendMsg(ws, response, useBinary)
  })
})

function sendMsg(ws: WebSocket, msg: unknown, binary: boolean): void {
  if (ws.readyState !== WebSocket.OPEN) return
  if (binary) {
    ws.send(encode(msg))          // MessagePack binary frame
  } else {
    ws.send(JSON.stringify(msg))  // JSON text frame
  }
}

// ── Browser client: negotiate and use MessagePack ─────────────────
import { encode, decode } from "@msgpack/msgpack"

const ws = new WebSocket("wss://api.example.com/ws")
ws.binaryType = "arraybuffer"   // receive binary frames as ArrayBuffer

ws.addEventListener("open", () => {
  // Keep negotiation as JSON — it is always the safe fallback both sides understand
  ws.send(JSON.stringify({ type: "negotiate", encoding: "msgpack" }))
})

ws.addEventListener("message", (event) => {
  if (event.data instanceof ArrayBuffer) {
    const msg = decode(new Uint8Array(event.data))
    console.log("MessagePack:", msg)
  } else {
    const msg = JSON.parse(event.data as string)
    console.log("JSON:", msg)
  }
})

// ── Payload size comparison ───────────────────────────────────────
const payload = {
  type: "sensor.reading",
  payload: {
    deviceId: "d-001",
    ts: 1734567890123,
    // 100 floating-point values — numeric arrays benefit most from MessagePack
    values: Array.from({ length: 100 }, (_, i) => 20 + i * 0.1),
  },
}

const jsonBytes = new TextEncoder().encode(JSON.stringify(payload)).length
const mpBytes   = encode(payload).length
const saved     = (((jsonBytes - mpBytes) / jsonBytes) * 100).toFixed(1)
console.log(`JSON: ${jsonBytes}B  MessagePack: ${mpBytes}B  savings: ${saved}%`)
// Typical: JSON ~2100B  MessagePack ~1050B  savings ~50% for numeric arrays
// String-heavy data: savings typically 5–20% only

// ── Format decision guide ─────────────────────────────────────────
// JSON:        debugging ease, broad tool support, string-heavy payloads
// MessagePack: >100 msg/s per connection, numeric arrays, mobile/IoT bandwidth
// CBOR:        IoT standards (CoAP), RFC 7049 compliance requirements

The negotiation handshake keeps the initial exchange as JSON text — it is always the safe fallback that both sides understand before agreeing on a binary format. MessagePack compression is most dramatic for numeric arrays (50%+ savings) and least dramatic for short string-heavy data (5–10%). Before switching to binary frames, verify that all clients support the chosen format — browser DevTools show binary WebSocket frames as raw bytes rather than readable text, which significantly slows debugging. For IoT and embedded devices, CBOR (RFC 7049) is often preferred over MessagePack because of its IETF standardisation and native support in CoAP stacks.

Socket.IO JSON Integration and Room Broadcasting

Socket.IO wraps the ws library and adds named events, rooms, namespaces, acknowledgement callbacks, and automatic reconnection. JSON encoding is handled automatically — pass JavaScript objects directly to socket.emit() without calling JSON.stringify(). The trade-off is approximately 30 KB added to the client bundle and a dependency on Socket.IO's own handshake protocol, which means raw WebSocket clients cannot connect directly.

// ── Install: npm install socket.io socket.io-client ───────────────

// ── Typed event interfaces ────────────────────────────────────────
interface ServerToClientEvents {
  "chat.message": (payload: { text: string; from: string; roomId: string }) => void
  error:          (payload: { code: number; message: string }) => void
}

interface ClientToServerEvents {
  "chat.message": (
    payload: { text: string; roomId: string },
    ack: (ok: boolean) => void,  // acknowledgement callback
  ) => void
  "room.join":  (roomId: string) => void
  "room.leave": (roomId: string) => void
}

// ── Server (Node.js) ──────────────────────────────────────────────
import { createServer } from "http"
import { Server, Socket } from "socket.io"

const httpServer = createServer()
const io = new Server<ClientToServerEvents, ServerToClientEvents>(httpServer, {
  cors: { origin: "http://localhost:3000", methods: ["GET", "POST"] },
})

io.on("connection", (socket: Socket<ClientToServerEvents, ServerToClientEvents>) => {
  console.log("Client connected:", socket.id)

  socket.on("room.join", (roomId) => {
    socket.join(roomId)          // Socket.IO room — no manual Set management
    console.log(`${socket.id} joined ${roomId}`)
  })

  socket.on("room.leave", (roomId) => socket.leave(roomId))

  socket.on("chat.message", (payload, ack) => {
    if (!payload.text || payload.text.length > 2000) {
      socket.emit("error", { code: 1006, message: "Invalid message" })
      ack(false)
      return
    }
    // Broadcast to everyone in the room except the sender
    socket.to(payload.roomId).emit("chat.message", {
      text:   payload.text,
      from:   socket.id,
      roomId: payload.roomId,
    })
    ack(true)   // confirms server received and processed the message
  })

  socket.on("disconnect", (reason) => {
    console.log(`${socket.id} disconnected: ${reason}`)
  })
})

httpServer.listen(3000)

// ── Broadcast patterns ────────────────────────────────────────────
// Send to a specific client
io.to(socket.id).emit("chat.message", payload)

// Broadcast to all in a room including sender
io.to("room-general").emit("chat.message", payload)

// Broadcast to all in a room excluding sender
socket.to("room-general").emit("chat.message", payload)

// Broadcast to all connected clients
io.emit("chat.message", payload)

// ── Client (browser) ──────────────────────────────────────────────
import { io as socketIo } from "socket.io-client"
import type { Socket } from "socket.io-client"

const socket: Socket<ServerToClientEvents, ClientToServerEvents> = socketIo("http://localhost:3000", {
  reconnectionDelayMax: 30_000,    // cap on Socket.IO built-in exponential backoff
  reconnectionAttempts: Infinity,  // retry forever
})

socket.on("connect", () => {
  console.log("Connected:", socket.id)
  socket.emit("room.join", "room-general")
})

socket.on("chat.message", (payload) => {
  console.log(`${payload.from}: ${payload.text}`)
})

// Emit with acknowledgement callback
socket.emit("chat.message", { text: "Hello", roomId: "room-general" }, (ok) => {
  if (!ok) console.error("Message rejected by server")
})

Socket.IO's acknowledgement callbacks are one of its most useful features over raw ws — they provide reliable at-least-once delivery confirmation without building a correlation ID system manually. Socket.IO also handles the WebSocket upgrade from HTTP and falls back to HTTP long-polling when WebSocket is blocked by corporate proxies — a common issue in enterprise deployments. The typed event interfaces (ServerToClientEvents, ClientToServerEvents) give end-to-end type safety: TypeScript enforces that socket.emit("chat.message", ...) matches the declared payload type exactly. For patterns around streaming large JSON payloads over connected clients, see our JSON streaming guide.

TypeScript Types for WebSocket JSON Messages

TypeScript discriminated unions provide compile-time safety for WebSocket message dispatch. The key is a literal type field in each message variant — TypeScript narrows the union inside switch (msg.type) case branches automatically, giving full access to payload fields without casting. A never assertion in the default branch causes a compile error when a new message type is added to the union but its handler is missing.

// ── Discriminated union: client → server ─────────────────────────
type PingMessage = { type: "ping" }

type ChatMessage = {
  type: "chat.message"
  payload: { text: string; roomId: string }
}

type SubscribeMessage = {
  type: "subscribe"
  payload: { channel: "prices" | "orderbook" | "trades"; symbol: string }
}

type ResumeMessage = {
  type: "resume"
  payload: { lastSeq: number }
}

type ClientMessage = PingMessage | ChatMessage | SubscribeMessage | ResumeMessage

// ── Discriminated union: server → client ─────────────────────────
type PongMessage = {
  type: "pong"
  ts: number
  seq: number
}

type PriceUpdate = {
  type: "price.update"
  seq: number
  payload: { symbol: string; bid: number; ask: number; ts: number }
}

type ErrorMessage = {
  type: "error"
  code: number
  message: string
  errors?: unknown[]
}

type ServerMessage = PongMessage | PriceUpdate | ErrorMessage

// ── Type guard: unknown → ServerMessage ──────────────────────────
function isServerMessage(x: unknown): x is ServerMessage {
  return (
    typeof x === "object" &&
    x !== null &&
    "type" in x &&
    typeof (x as Record<string, unknown>).type === "string"
  )
}

// ── Parse and narrow with switch ──────────────────────────────────
function handleServerMessage(raw: string): void {
  let parsed: unknown
  try { parsed = JSON.parse(raw) }
  catch { console.error("Invalid JSON from server"); return }

  if (!isServerMessage(parsed)) {
    console.error("Unknown message shape:", parsed)
    return
  }

  switch (parsed.type) {
    case "pong":
      // TypeScript knows: parsed is PongMessage — ts and seq are accessible
      console.log("Pong, server ts:", parsed.ts, "seq:", parsed.seq)
      break

    case "price.update":
      // TypeScript knows: parsed is PriceUpdate
      console.log(`${parsed.payload.symbol}: ${parsed.payload.bid}/${parsed.payload.ask}`)
      break

    case "error":
      // TypeScript knows: parsed is ErrorMessage
      console.error(`Server error ${parsed.code}: ${parsed.message}`, parsed.errors)
      break

    default: {
      // Exhaustive check: compile error if ServerMessage grows and a case is missing
      const _exhaustive: never = parsed
      console.warn("Unhandled type:", (_exhaustive as ServerMessage).type)
    }
  }
}

// ── Typed send helper — enforces correct payload at call site ─────
function send(ws: WebSocket, msg: ClientMessage): void {
  if (ws.readyState !== WebSocket.OPEN) return
  ws.send(JSON.stringify(msg))
}

// TypeScript enforces correct shape at every call site
send(ws, { type: "ping" })
send(ws, { type: "chat.message", payload: { text: "Hi", roomId: "abc" } })
// send(ws, { type: "chat.message", payload: { text: "Hi" } })
//   ↑ compile error: property 'roomId' is missing

// ── @sinclair/typebox: single source of truth for schema + type ───
// npm install @sinclair/typebox
import { Type, Static } from "@sinclair/typebox"
import Ajv from "ajv"

const ChatMessageSchema = Type.Object({
  type:    Type.Literal("chat.message"),
  payload: Type.Object({
    text:   Type.String({ minLength: 1, maxLength: 2000 }),
    roomId: Type.String({ format: "uuid" }),
  }),
})

// Derive TypeScript type from the schema — one definition, no drift
type ChatMessageFromSchema = Static<typeof ChatMessageSchema>
// Equivalent to: { type: "chat.message"; payload: { text: string; roomId: string } }

const validate = new Ajv().compile(ChatMessageSchema)
// validate is both a runtime validator and a TypeScript type guard

The never exhaustive check in the default case is the most valuable TypeScript pattern for message dispatch — it turns a runtime bug (unhandled message type) into a compile-time error, caught during development rather than production. The @sinclair/typebox library provides a single source of truth for both JSON Schema (used by AJV for runtime validation) and TypeScript type (used by the compiler for static checking), eliminating the risk of the two drifting out of sync as the protocol evolves. For additional type-safe JSON parsing patterns outside of WebSocket, see our JSON security guide.

Key Terms

WebSocket
A full-duplex, persistent TCP-based communication protocol standardised in RFC 6455. A WebSocket connection starts as an HTTP request and upgrades via an Upgrade: websocket header. Unlike HTTP, both sides can send messages at any time without waiting for a request — enabling server push, real-time notifications, and bidirectional streaming. WebSocket frames carry no Content-Type header; the protocol does not define a message format. Sending JSON requires JSON.stringify() before sending and JSON.parse() on receipt. WebSocket connections persist until explicitly closed or until a network interruption drops them. The native browser WebSocket API and the Node.js ws library both implement RFC 6455.
JSON frame
A WebSocket text frame whose payload is a JSON-encoded UTF-8 string. WebSocket frames are classified as text frames (UTF-8 payload) or binary frames (raw bytes). JSON is sent as a text frame — ws.send(JSON.stringify(obj)) creates a text frame automatically. Binary formats (MessagePack, CBOR) are sent as binary frames — ws.send(Buffer.from(encode(obj))). Since WebSocket frames carry no Content-Type header, the receiver cannot determine the encoding from the protocol alone — both sides must agree on a convention (text frames = JSON, binary frames = MessagePack). Browser DevTools display text frame payloads as readable strings in the Network WebSocket inspector; binary frames appear as raw bytes.
type discriminator
A field in a JSON message whose value identifies the message kind, enabling the receiver to select the correct handler without inspecting the full message shape. The canonical convention is a type string field with a dot-separated hierarchical name: chat.message, price.update, room.join. In TypeScript, a discriminated union with a literal type field enables exhaustive type narrowing in a switch statement — each case branch has access to the variant-specific payload fields without explicit casting. The type field must be at the top level of the message envelope, not nested inside a payload, so receivers can dispatch before parsing the rest of the message. Alternative names include action (Redux-style), event, and kind.
exponential backoff
A retry strategy where the delay between successive reconnection attempts doubles after each failure, starting from a minimum delay and capping at a maximum. The standard formula is delay = min(minDelay × 2^attempt, maxDelay). For WebSocket reconnection, the typical configuration starts at 1 second and caps at 30 seconds. Random jitter (±20%) is added to desynchronise reconnection attempts from multiple clients — without jitter, all clients that disconnect simultaneously (during a server restart) would attempt to reconnect at the exact same moment, overwhelming the server. The attempt counter resets to 0 on a successful connection. Reconnection should not trigger for intentional server-initiated closes (WebSocket close code 1000 Normal Closure).
ws (library)
A minimal, high-performance WebSocket server and client library for Node.js (npm install ws). It provides WebSocketServer for servers and WebSocket for clients. The library sends and receives raw strings and Buffers — JSON serialization is not automatic. Key events: connection (new client), message (incoming data, with an isBinary flag), close (disconnection with code and reason), error, ping, and pong. The ws.send(data) method accepts a string (text frame) or Buffer (binary frame). TypeScript types are provided via @types/ws. The ws library is the underlying transport for Socket.IO and many other Node.js WebSocket frameworks.
Socket.IO
A WebSocket abstraction library (npm install socket.io) that adds named events, rooms, namespaces, acknowledgement callbacks, automatic JSON serialization, and built-in reconnection with exponential backoff. The server broadcasts with io.to("room").emit("event", data); the client listens with socket.on("event", handler). Pass JavaScript objects directly without calling JSON.stringify(). Socket.IO falls back to HTTP long-polling when WebSocket is blocked by proxies or firewalls. The client-side library adds approximately 30 KB to the browser bundle. TypeScript generics support typed event maps for both directions: Socket<ClientToServerEvents, ServerToClientEvents>. Raw WebSocket clients cannot connect to a Socket.IO server directly — both sides must use the Socket.IO protocol.
MessagePack
A binary serialization format that encodes the same data structures as JSON (objects, arrays, strings, numbers, booleans, null) in a compact binary representation. MessagePack produces 5–20% smaller payloads than JSON for string-heavy data and 40–60% smaller for numeric arrays — because JSON encodes numbers as decimal text while MessagePack uses fixed-width binary integers and IEEE 754 floats. Encode with encode(data) from @msgpack/msgpack; decode with decode(buffer). MessagePack is sent over WebSocket as a binary frame. It is not human-readable — browser DevTools show raw bytes instead of text, making debugging harder than with JSON. Use MessagePack when profiling shows JSON serialisation is a measurable bottleneck, typically at high message rates or with large numeric payloads.

FAQ

How do I send and receive JSON over WebSockets in Node.js?

Install the ws library: npm install ws. On the server, create a WebSocketServer and listen for the message event. Incoming messages arrive as a Buffer — call JSON.parse(data.toString()) to deserialise. Send JSON by calling ws.send(JSON.stringify(payload)) — the ws library does not serialise objects automatically. On the browser, use the native WebSocket API: const ws = new WebSocket("ws://localhost:8080"); listen on ws.onmessage = (e) => JSON.parse(e.data); send with ws.send(JSON.stringify({ type: "ping" })). Always wrap JSON.parse in a try/catch — malformed input throws SyntaxError. Include a type field in every message so the receiver dispatches to the correct handler without inspecting message shape. Close connections that send invalid JSON using ws.close(1003, "Unsupported data").

What is a WebSocket message framing convention for JSON?

A framing convention is a shared structure that both sides agree to for every WebSocket JSON message. The most reliable pattern is the type discriminator envelope: every message includes a type string field identifying the message kind, plus a payload object with message-specific data. Example: { "type": "chat.message", "payload": { "text": "hello", "roomId": "r1" } }. The receiver parses the JSON, reads msg.type, and invokes the matching handler: handlerMap[msg.type]?.(msg.payload). Optionally add an id field for request/response correlation (the server echoes the ID back in its reply), a v field for protocol versioning, and a seq field for sequence-based state recovery after reconnection. Without a consistent framing convention, receivers must guess message kind from shape — a fragile pattern that breaks when two message types share overlapping fields.

How do I validate JSON messages received over WebSocket?

Use AJV to validate incoming messages against a JSON Schema compiled at server startup. Install: npm install ajv ajv-formats. Compile schemas once — do not compile inside the message handler. For each message type, define a schema and compile: const validate = ajv.compile(schema). In the message handler: parse JSON, check the type field exists, look up the validator by type, call validate(parsed), and reject with a structured error if it fails: ws.send(JSON.stringify({ type: "error", errors: validate.errors })). Use additionalProperties: false in every schema to reject unexpected fields. Use allErrors: true when constructing AJV to return all validation errors at once. AJV validates a WebSocket message in under 0.1 ms. For production, pre-compile validators with npx ajv compile to eliminate the AJV runtime from the hot path entirely.

How do I handle WebSocket reconnection with JSON state recovery?

Implement exponential backoff on the client: delay = Math.min(1000 * 2 ** attempt, 30000), plus ±20% random jitter, incrementing attempt on each failure and resetting to 0 on success. Check event.wasClean && event.code === 1000 in the onclose handler before reconnecting — intentional closes must not trigger backoff. State recovery means sending a resume message immediately after the connection opens: ws.send(JSON.stringify({ type: "resume", payload: { lastSeq: 42 } })), where lastSeq is the highest sequence number received before disconnection. The server replays all events after lastSeq, ensuring no messages are lost. Buffer outbound messages in an array during the disconnected window and flush them after the recovery message is sent. Always clear any pending reconnect timer before creating a new connection to prevent duplicate connections.

What is the difference between ws and Socket.IO for JSON WebSockets?

ws is a minimal library — it sends raw strings and Buffers, requiring manual JSON.stringify() and JSON.parse() on every message. It adds zero overhead beyond the WebSocket protocol and is correct when bundle size matters, when full wire format control is needed, or when connecting non-browser clients. Socket.IO wraps ws and adds automatic JSON encoding, named events (replacing manual type dispatch), rooms and namespaces (replacing manual Set management), acknowledgement callbacks, and built-in reconnection — but adds approximately 30 KB to the client bundle and requires its own handshake protocol, meaning raw WebSocket clients cannot connect. Choose wsfor lean, low-latency applications. Choose Socket.IO when rooms and broadcasting, event namespacing, or out-of-the-box reconnection are worth the bundle cost. Socket.IO's acknowledgement callbacks provide reliable delivery confirmation without building a correlation ID system from scratch.

Should I use JSON or binary (MessagePack) for WebSocket messages?

Use JSON by default — it is human-readable in browser DevTools, universally supported, and trivial to debug. JSON adds 5–20% overhead versus MessagePack for typical string-heavy data and up to 50% overhead for large numeric arrays. Switch to MessagePack when profiling shows serialisation is a measurable bottleneck: typically at more than 100 messages per second per connection, or for messages with large floating-point arrays (game state, sensor readings). Both sides must agree on the encoding — implement a negotiation handshake (client sends { "type": "negotiate", "encoding": "msgpack" }, server acknowledges) rather than hard-coding. Set ws.binaryType = "arraybuffer" in the browser and use the isBinary flag in the ws library message event to detect frame type. CBOR is an alternative to MessagePack when IETF RFC 7049 compliance is required for IoT or CoAP environments.

How do I broadcast JSON to multiple WebSocket clients?

With the ws library, maintain a Set of connected clients: add each client in the connection handler, remove it in the close handler. Broadcast by iterating the Set: const data = JSON.stringify(msg); for (const client of clients) { if (client.readyState === WebSocket.OPEN) client.send(data); }. Serialise JSON once before the loop — not once per client — to avoid redundant work proportional to the number of clients. Check readyState === WebSocket.OPEN before sending to skip connections that are closing or already closed. For room-based broadcasting, use a Map<string, Set<WebSocket>> keyed by room name. With Socket.IO, room broadcasting is built in: io.to("room-name").emit("event", payload) — no manual iteration or Set management required, and serialisation is automatic.

How do I type WebSocket JSON messages in TypeScript?

Use a discriminated union where each message variant has a literal type field: type ChatMessage = { type: "chat.message"; payload: { text: string; roomId: string } }. Combine all variants: type ClientMessage = PingMessage | ChatMessage | SubscribeMessage. TypeScript narrows the union automatically in a switch (msg.type) — each case branch has full access to variant-specific payload fields without casting. For incoming messages from JSON.parse() (which returns unknown), write a type guard: function isServerMessage(x: unknown): x is ServerMessage { return typeof x === "object" && x !== null && "type" in x; }. Add a never assertion in the default case to get a compile error when a new type is added to the union but its handler is missing. Use @sinclair/typebox to derive both the JSON Schema (for AJV runtime validation) and the TypeScript type from a single definition — eliminating drift between the two representations as the protocol evolves.

Further reading and primary sources

  • ws npm packageOfficial npm page for the ws WebSocket library — server and client for Node.js
  • Socket.IO documentationOfficial Socket.IO v4 documentation — rooms, namespaces, events, and acknowledgements
  • AJV JSON Schema validatorAJV documentation — compile JSON Schemas to fast validation functions for incoming WebSocket messages
  • MessagePack for JavaScriptOfficial @msgpack/msgpack package — binary serialization alternative to JSON for WebSocket frames
  • RFC 6455: The WebSocket ProtocolIETF specification for the WebSocket protocol — frame format, handshake, and close codes
  • @sinclair/typeboxTypeBox — derive TypeScript types and JSON Schemas from a single definition for AJV validation