WebSocket JSON Messages: Protocol, Framing, Heartbeat & Node.js
Last updated:
WebSocket transmits JSON as UTF-8 text frames — a single call to JSON.stringify(payload) on the sender and JSON.parse(event.data) on the receiver handles all serialization, with no HTTP overhead after the initial handshake. A minimal WebSocket JSON message envelope has three fields: type (string discriminant), payload (object), and id (correlation string); this pattern keeps handlers O(1) dispatch rather than parsing content to determine message intent. This guide covers the WebSocket framing model for JSON, designing message envelopes, heartbeat and reconnection logic, binary alternatives (MessagePack), Socket.IO JSON event model, and Node.js ws server implementation.
WebSocket JSON Framing and the Text Frame Model
The WebSocket protocol (RFC 6455) defines two data frame types relevant to JSON: text frames (opcode 0x1) carry UTF-8 encoded strings, and binary frames (opcode 0x2) carry raw bytes. JSON is always sent as text frames — JSON.stringify() produces a UTF-8 string that maps directly to a WebSocket text frame with no additional encoding. The theoretical maximum frame size is 16 MB (64-bit payload length field), but practical implementations cap at 1 MB per frame to prevent memory exhaustion; the ws library defaults to 100 MB (maxPayload option) and should be set explicitly. For JSON payloads larger than the frame limit, WebSocket supports fragmentation: the first fragment has the opcode set and FIN bit cleared, intermediate fragments use opcode 0x0 (continuation) with FIN cleared, and the final fragment uses opcode 0x0 with FIN set. In practice, most applications avoid fragmentation by keeping individual JSON messages under 64 KB.
// Browser WebSocket — send and receive JSON as text frames
const ws = new WebSocket('wss://api.example.com/ws')
// Send JSON — JSON.stringify produces a UTF-8 string → text frame
ws.onopen = () => {
ws.send(JSON.stringify({
type: 'subscribe',
payload: { channel: 'prices', symbol: 'BTC' },
id: 'req-1',
}))
}
// Receive JSON — event.data is already a string for text frames
ws.onmessage = (event) => {
try {
const msg = JSON.parse(event.data)
console.log(msg.type, msg.payload)
} catch (err) {
console.error('Malformed JSON frame:', event.data)
}
}
// WebSocket frame structure (simplified RFC 6455):
// Bit 0: FIN — 1 if last/only fragment
// Bits 1-3: RSV1-3 — reserved (WebSocket extensions use these)
// Bits 4-7: Opcode — 0x1=text, 0x2=binary, 0x8=close, 0x9=ping, 0xA=pong
// Bit 8: MASK — client-to-server frames MUST be masked
// Bits 9-15: Payload length (0-125, or 126/127 for extended)
// [Masking key: 4 bytes if MASK=1]
// [Payload data]
// Node.js ws library — maxPayload controls max frame size
import { WebSocketServer } from 'ws'
const wss = new WebSocketServer({
port: 8080,
maxPayload: 1024 * 1024, // 1 MB per frame (default: 100 MB — set explicitly)
})
// Large JSON payload fragmentation (ws library handles automatically)
// For payloads approaching maxPayload, split at the application layer:
function sendChunkedJson(ws: WebSocket, data: unknown[], chunkSize = 100) {
for (let i = 0; i < data.length; i += chunkSize) {
ws.send(JSON.stringify({
type: 'chunk',
payload: data.slice(i, i + chunkSize),
meta: { offset: i, total: data.length },
id: `chunk-${i}`,
}))
}
}Client-to-server WebSocket frames must be masked (XOR with a 4-byte masking key) per RFC 6455 — browsers do this automatically and the ws library handles unmasking transparently. Server-to-client frames must NOT be masked. The masking prevents proxy cache poisoning attacks where an attacker could craft a WebSocket frame that looks like an HTTP response to a caching proxy. For JSON-heavy applications, gzip compression via the permessage-deflate WebSocket extension (enabled by default in the ws library) compresses JSON frames by 60–80% since JSON keys repeat across messages; enable it with perMessageDeflate: true in the server options.
Designing a JSON Message Envelope
A message envelope is the standard JSON structure wrapping every WebSocket message, enabling type-based dispatch and request-response correlation over a single persistent connection. The minimal envelope has three fields: type (string discriminant for O(1) handler lookup), payload (message-specific data object), and id (correlation string — UUID or incrementing counter — for matching server responses to client requests). An optional v field supports protocol versioning. TypeScript discriminated union types provide compile-time safety across all message variants.
// TypeScript discriminated union — one type per message kind
type SubscribeMsg = { type: 'subscribe'; payload: { channel: string; symbol: string }; id: string; v: 1 }
type UnsubscribeMsg = { type: 'unsubscribe'; payload: { channel: string }; id: string; v: 1 }
type PingMsg = { type: 'ping'; payload: Record<string, never>; id: string; v: 1 }
type PriceMsg = { type: 'price'; payload: { symbol: string; price: number; ts: number }; id: string; v: 1 }
type ErrorMsg = { type: 'error'; payload: { code: string; message: string }; id: string | null; v: 1 }
type ClientMessage = SubscribeMsg | UnsubscribeMsg | PingMsg
type ServerMessage = PriceMsg | ErrorMsg
// O(1) dispatch table — no switch/case on payload content
const handlers: Record<ClientMessage['type'], (msg: ClientMessage) => void> = {
subscribe: (msg) => { /* narrowed to SubscribeMsg */ subscribe((msg as SubscribeMsg).payload) },
unsubscribe: (msg) => { unsubscribe((msg as UnsubscribeMsg).payload) },
ping: (msg) => { sendPong(msg.id) },
}
// Server-side dispatch
function handleMessage(ws: WebSocket, raw: Buffer) {
let msg: ClientMessage
try {
msg = JSON.parse(raw.toString())
} catch {
sendError(ws, null, 'INVALID_JSON', 'Message is not valid JSON')
return
}
const handler = handlers[msg.type]
if (!handler) {
sendError(ws, msg.id, 'UNKNOWN_TYPE', `Unknown message type: ${msg.type}`)
return
}
handler(msg)
}
// Correlation IDs — match server responses to client requests
// Client sends id: "req-uuid-1234"
// Server echoes id: "req-uuid-1234" in the response
// Client resolves the pending Promise for that id
const pending = new Map<string, { resolve: (v: ServerMessage) => void; reject: (e: Error) => void }>()
function request(ws: WebSocket, msg: ClientMessage): Promise<ServerMessage> {
return new Promise((resolve, reject) => {
pending.set(msg.id, { resolve, reject })
ws.send(JSON.stringify(msg))
// Timeout: reject if no response within 5 seconds
setTimeout(() => {
if (pending.has(msg.id)) {
pending.delete(msg.id)
reject(new Error(`Request ${msg.id} timed out`))
}
}, 5000)
})
}
// Incoming response — resolve the matching pending request
ws.onmessage = (event) => {
const msg: ServerMessage = JSON.parse(event.data)
const waiter = pending.get(msg.id ?? '')
if (waiter) {
pending.delete(msg.id ?? '')
waiter.resolve(msg)
}
}The v versioning field allows protocol evolution without breaking existing clients: a server that receives v: 1 messages can handle them with the old schema while a client sending v: 2 messages gets new behavior. When adding new message types, add them as new discriminants to the union rather than adding optional fields to existing types — this keeps handlers focused and TypeScript narrowing precise. The correlation id field transforms WebSocket from a fire-and-forget channel into a request-response protocol: clients can use UUID v4 or a simple counter (\`req-\${++counter}\`) as the ID.
Heartbeat, Ping-Pong, and Connection Health
WebSocket connections can silently drop due to proxy timeouts, NAT table expiry, or network interruptions — without a heartbeat, the application may not detect a dead connection for minutes. RFC 6455 defines protocol-level ping (opcode 0x9) and pong (opcode 0xA) frames: the server sends a ping, the client must respond with a pong within a reasonable timeout. Most load balancers and proxies drop idle WebSocket connections after 60 seconds; a 25-second heartbeat interval keeps the connection alive and detects failures within one interval. Application-level JSON heartbeats ({"type":"ping"}) are an alternative when protocol-level ping/pong is not accessible (e.g., browser clients cannot send protocol pings).
import { WebSocketServer, WebSocket } from 'ws'
const HEARTBEAT_INTERVAL = 25_000 // 25 seconds
const HEARTBEAT_TIMEOUT = 10_000 // 10 seconds to respond
// Server-side protocol ping/pong with per-connection liveness tracking
const wss = new WebSocketServer({ port: 8080 })
wss.on('connection', (ws) => {
// Per-connection state
let isAlive = true
let timeoutId: ReturnType<typeof setTimeout> | null = null
// Browser pong (automatic response to protocol ping)
ws.on('pong', () => {
isAlive = true
if (timeoutId) clearTimeout(timeoutId)
})
// Heartbeat loop
const interval = setInterval(() => {
if (!isAlive) {
// No pong received — connection is dead, terminate it
ws.terminate() // hard close (not graceful), triggers reconnection on client
return
}
isAlive = false // reset; set to true when pong arrives
ws.ping() // protocol-level ping — browser responds automatically
// Timeout: if pong not received within 10 s, mark as dead on next tick
timeoutId = setTimeout(() => { isAlive = false }, HEARTBEAT_TIMEOUT)
}, HEARTBEAT_INTERVAL)
ws.on('close', () => clearInterval(interval))
})
// ── Application-level JSON heartbeat (for clients that can't send protocol pings)
// Client sends: { "type": "ping", "payload": {}, "id": "ping-1716124800" }
// Server responds: { "type": "pong", "payload": { "ts": 1716124800000 }, "id": "ping-1716124800" }
// ── Client-side reconnection with exponential backoff ──────────
function createReconnectingWebSocket(url: string) {
let ws: WebSocket
let attempt = 0
let intentionallyClosed = false
function connect() {
ws = new WebSocket(url)
ws.onopen = () => {
attempt = 0 // reset backoff on successful connection
// Re-send auth and subscriptions after reconnect
ws.send(JSON.stringify({ type: 'auth', payload: { token: getToken() }, id: 'auth' }))
for (const sub of activeSubscriptions) {
ws.send(JSON.stringify({ type: 'subscribe', payload: sub, id: `resub-${sub.channel}` }))
}
}
ws.onclose = (event) => {
if (intentionallyClosed) return
// Exponential backoff: 1s → 2s → 4s → 8s → 16s → 30s (cap)
const delay = Math.min(1000 * Math.pow(2, attempt), 30_000)
attempt++
console.log(`WebSocket closed (code ${event.code}). Reconnecting in ${delay}ms (attempt ${attempt})`)
setTimeout(connect, delay)
}
ws.onerror = (err) => console.error('WebSocket error:', err)
}
connect()
return {
send: (data: unknown) => ws.send(JSON.stringify(data)),
close: () => { intentionallyClosed = true; ws.close(1000, 'Client closed') },
}
}
const activeSubscriptions: Array<{ channel: string; symbol: string }> = []Use ws.terminate() (hard close, no closing handshake) rather than ws.close() (graceful close) when a dead connection is detected by heartbeat timeout — a dead connection will not respond to the closing handshake and terminate() immediately frees server resources. On the client side, reconnect only when intentionallyClosed is false to avoid reconnection loops after a deliberate logout. Always reconnect with a fresh subscription list rather than assuming server-side state persists — most WebSocket servers are stateless and discard per-connection state on disconnect.
Handling JSON Parsing Errors and Validation
Robust WebSocket servers validate every incoming message at two levels: JSON syntax (via JSON.parse in a try/catch) and schema conformance (via a validator such as Zod). Skipping validation allows malformed messages to crash the server, trigger unexpected handler behavior, or enable injection attacks via unexpected field values. The validation error response must itself be valid JSON and must reference the original message's id so the client can correlate the error to the failed request.
import { z } from 'zod'
import { WebSocketServer, WebSocket } from 'ws'
// ── Schema definitions with Zod ────────────────────────────────
const BaseSchema = z.object({
type: z.string(),
id: z.string(),
v: z.literal(1).optional(),
})
const SubscribeSchema = BaseSchema.extend({
type: z.literal('subscribe'),
payload: z.object({
channel: z.enum(['prices', 'trades', 'orderbook']),
symbol: z.string().regex(/^[A-Z]{2,10}$/),
}),
})
const PingSchema = BaseSchema.extend({
type: z.literal('ping'),
payload: z.object({}).strict(),
})
// Union of all valid client message schemas
const ClientMessageSchema = z.discriminatedUnion('type', [
SubscribeSchema,
PingSchema,
// ... other message schemas
])
// ── Validation helper ──────────────────────────────────────────
function sendError(ws: WebSocket, id: string | null, code: string, message: string) {
ws.send(JSON.stringify({
type: 'error',
payload: { code, message },
id,
v: 1,
}))
}
// ── Per-connection violation tracking ─────────────────────────
const violations = new Map<WebSocket, number>()
const MAX_VIOLATIONS = 5
const wss = new WebSocketServer({ port: 8080 })
wss.on('connection', (ws) => {
violations.set(ws, 0)
ws.on('message', (raw) => {
// Stage 1: JSON syntax validation
let parsed: unknown
try {
parsed = JSON.parse(raw.toString())
} catch {
const count = (violations.get(ws) ?? 0) + 1
violations.set(ws, count)
sendError(ws, null, 'INVALID_JSON', 'Message is not valid JSON')
if (count >= MAX_VIOLATIONS) {
ws.close(1008, 'Policy Violation: too many invalid messages')
}
return
}
// Stage 2: Schema validation with Zod
const result = ClientMessageSchema.safeParse(parsed)
if (!result.success) {
const id = (parsed as { id?: string })?.id ?? null
const issues = result.error.issues.map(i => ({
path: i.path.join('.'),
message: i.message,
}))
sendError(ws, id, 'INVALID_SCHEMA', JSON.stringify(issues))
const count = (violations.get(ws) ?? 0) + 1
violations.set(ws, count)
if (count >= MAX_VIOLATIONS) {
ws.close(1008, 'Policy Violation: repeated schema violations')
}
return
}
// Stage 3: Dispatch to handler (type is narrowed by discriminatedUnion)
const msg = result.data
violations.set(ws, 0) // reset violation counter on valid message
handlers[msg.type](ws, msg)
})
ws.on('close', () => violations.delete(ws))
})Use z.discriminatedUnion('type', [...]) rather than z.union([...]) for WebSocket message schemas — discriminated union validation is O(1) (it looks up the matching schema by the discriminant value) while regular union validation is O(n) (it tries each schema in order). Never log the raw invalid message to production logs without sanitization — an attacker can inject log-poisoning payloads (ANSI escape codes, newline-terminated log entries) via malformed WebSocket frames. See the safe JSON.parse TypeScript guide for more validation patterns.
Socket.IO JSON Event Model
Socket.IO is a WebSocket abstraction library that adds rooms, namespaces, acknowledgments, and automatic reconnection on top of raw WebSocket (with HTTP long-polling fallback). Every Socket.IO JSON message is framed by Engine.IO: a text frame starting with 42 (4 = Engine.IO message packet, 2 = Socket.IO event packet) followed by a JSON array of [eventName, ...args]. Understanding the Socket.IO wire protocol helps debug raw WebSocket frames in browser DevTools.
// Socket.IO wire format (what you see in DevTools WebSocket frames)
// 42["subscribe",{"channel":"prices","symbol":"BTC"}]
// │└ Socket.IO event packet type (2 = EVENT)
// └── Engine.IO message packet type (4 = MESSAGE)
// ── Server setup with namespaces ──────────────────────────────
import { Server } from 'socket.io'
import { createServer } from 'http'
const httpServer = createServer()
const io = new Server(httpServer, {
cors: { origin: 'https://example.com', credentials: true },
})
// Namespace: logical channel over one physical connection
const pricesNs = io.of('/prices')
const tradesNs = io.of('/trades')
pricesNs.on('connection', (socket) => {
console.log('Client connected to /prices:', socket.id)
// Typed JSON event handler
socket.on('subscribe', (data: { symbol: string }) => {
socket.join(`price:${data.symbol}`) // join a room
socket.emit('subscribed', { symbol: data.symbol, status: 'ok' })
})
socket.on('unsubscribe', (data: { symbol: string }) => {
socket.leave(`price:${data.symbol}`)
})
socket.on('disconnect', (reason) => {
console.log('Client disconnected:', reason)
})
})
// Broadcasting JSON to a room
function publishPrice(symbol: string, price: number) {
pricesNs.to(`price:${symbol}`).emit('price', {
symbol,
price,
ts: Date.now(),
})
}
// ── Acknowledgments — confirm JSON message delivery ────────────
// Client sends event + callback function
socket.emit('order', { symbol: 'BTC', qty: 0.1, side: 'buy' }, (ack: { orderId: string }) => {
console.log('Order confirmed:', ack.orderId)
})
// Server receives event + calls ack callback with response JSON
socket.on('order', (data, ack) => {
const orderId = processOrder(data)
ack({ orderId, status: 'accepted', ts: Date.now() })
})
// ── Multi-server fan-out with Redis adapter ────────────────────
import { createAdapter } from '@socket.io/redis-adapter'
import { createClient } from 'redis'
const pubClient = createClient({ url: 'redis://localhost:6379' })
const subClient = pubClient.duplicate()
await Promise.all([pubClient.connect(), subClient.connect()])
io.adapter(createAdapter(pubClient, subClient))
// Now io.to(room).emit() works across all server instances
// Redis Pub/Sub fan-out: server 1 publishes to Redis → all servers receive → forward to local clients
// ── Client-side Socket.IO JSON events ─────────────────────────
import { io as ioClient } from 'socket.io-client'
const socket = ioClient('https://api.example.com/prices', {
auth: { token: 'jwt-token-here' }, // sent in handshake
reconnectionDelay: 1000, // 1s initial backoff
reconnectionDelayMax: 30000, // 30s max backoff
randomizationFactor: 0.5, // jitter
})
socket.on('connect', () => {
socket.emit('subscribe', { symbol: 'BTC' })
})
socket.on('price', (data: { symbol: string; price: number; ts: number }) => {
updateUI(data)
})Socket.IO v4 acknowledgments transform WebSocket's fire-and-forget model into a reliable request-response protocol at the application layer — the server calls the ack callback with a JSON response, and the client receives it as a resolved Promise (using socket.emitWithAck()). The Redis adapter enables JSON broadcasting across multiple server instances without application-level coordination: any server calling io.to(room).emit(event, data) publishes to Redis, and all other servers pick it up and forward to their local clients in that room. For the multi-server pattern without Socket.IO, see the JSON event-driven architecture guide.
Node.js ws Library: Server-Side JSON Handling
The ws npm package is the most widely used Node.js WebSocket server library — it provides a lean, RFC-6455-compliant implementation without the higher-level abstractions of Socket.IO. Server setup, per-connection state management, JSON broadcasting, and JWT-based authentication are the four core patterns for production ws deployments. The library emits connection, message, close, and error events; all message data arrives as Buffer and must be converted to string before JSON.parse.
import { WebSocketServer, WebSocket } from 'ws'
import { IncomingMessage } from 'http'
import jwt from 'jsonwebtoken'
// ── Per-connection state ───────────────────────────────────────
interface ClientState {
userId: string
authenticated: boolean
subscriptions: Set<string>
messageCount: number
}
const clients = new Map<WebSocket, ClientState>()
// ── Server setup ───────────────────────────────────────────────
const wss = new WebSocketServer({
port: 8080,
maxPayload: 1024 * 64, // 64 KB max message size
perMessageDeflate: { // enable gzip compression for JSON frames
zlibDeflateOptions: { level: 6 },
threshold: 1024, // only compress messages > 1 KB
},
})
wss.on('connection', (ws: WebSocket, req: IncomingMessage) => {
// Initialize unauthenticated state
clients.set(ws, {
userId: '',
authenticated: false,
subscriptions: new Set(),
messageCount: 0,
})
ws.on('message', (raw: Buffer) => {
const state = clients.get(ws)!
state.messageCount++
let msg: { type: string; payload: Record<string, unknown>; id: string }
try {
msg = JSON.parse(raw.toString())
} catch {
ws.send(JSON.stringify({ type: 'error', payload: { code: 'INVALID_JSON' }, id: null }))
return
}
// First message MUST be auth
if (!state.authenticated) {
if (msg.type !== 'auth') {
ws.close(1008, 'First message must be auth')
return
}
try {
const decoded = jwt.verify(msg.payload.token as string, process.env.JWT_SECRET!) as { sub: string }
state.userId = decoded.sub
state.authenticated = true
ws.send(JSON.stringify({ type: 'auth_ok', payload: { userId: state.userId }, id: msg.id }))
} catch {
ws.close(1008, 'Invalid token')
}
return
}
// Dispatch to handlers
switch (msg.type) {
case 'subscribe':
state.subscriptions.add(msg.payload.channel as string)
ws.send(JSON.stringify({ type: 'subscribed', payload: { channel: msg.payload.channel }, id: msg.id }))
break
case 'ping':
ws.send(JSON.stringify({ type: 'pong', payload: { ts: Date.now() }, id: msg.id }))
break
default:
ws.send(JSON.stringify({ type: 'error', payload: { code: 'UNKNOWN_TYPE' }, id: msg.id }))
}
})
ws.on('close', () => clients.delete(ws))
ws.on('error', (err) => { console.error('WebSocket error:', err); clients.delete(ws) })
})
// ── Broadcast JSON to a channel (all subscribers) ─────────────
function broadcast(channel: string, data: unknown) {
const msg = JSON.stringify({ type: 'data', payload: data, id: null }) // serialize once
for (const [ws, state] of clients) {
if (state.authenticated && state.subscriptions.has(channel) && ws.readyState === WebSocket.OPEN) {
ws.send(msg)
}
}
}
// ── Integrate with HTTP server (share port with REST API) ──────
import { createServer } from 'http'
import express from 'express'
const app = express()
const server = createServer(app)
const wssShared = new WebSocketServer({ server }) // attach to existing HTTP server
app.get('/health', (_, res) => res.json({ status: 'ok', clients: clients.size }))
server.listen(8080)Serialize the broadcast JSON string once outside the client loop — calling JSON.stringify() inside a loop over 10,000 clients wastes CPU on identical serialization. The readyState === WebSocket.OPEN check before each send() is essential: clients in CLOSING or CLOSED state throw on send(). Attaching the WebSocketServer to an existing HTTP server via the server option (rather than the port option) lets WebSocket and REST API share port 80/443 — the HTTP server routes Upgrade requests to the WebSocket server automatically. See the JSON in serverless guide for patterns when WebSocket is not available.
Binary Alternatives: MessagePack and CBOR
MessagePack and CBOR are binary serialization formats that encode the same data as JSON but in smaller binary representations. MessagePack encodes equivalent JSON 20–50% smaller by replacing verbose JSON syntax ({"{"}, {"}"}, ":", ",", quotes) with compact binary type tags and length prefixes. CBOR (Concise Binary Object Representation, RFC 7049) is an IETF standard offering similar size benefits with additional type support (e.g., typed arrays, tagged values for dates/UUIDs). Both use WebSocket binary frames instead of text frames.
// Size comparison — same data in JSON vs MessagePack
// JSON (text frame):
// {"price":42381.5,"volume":1.23,"symbol":"BTC","ts":1716124800000}
// Length: 63 bytes
// MessagePack (binary frame):
// Approx 28-32 bytes (55% smaller)
// Binary encoding: fixmap(4 fields) + fixstr("price") + float64 + fixstr("volume") + float32 + ...
// ── Node.js server with MessagePack (msgpack-lite) ─────────────
import msgpack from 'msgpack-lite'
import { WebSocketServer, WebSocket } from 'ws'
const wss = new WebSocketServer({ port: 8080 })
wss.on('connection', (ws) => {
ws.on('message', (raw: Buffer) => {
// raw is already a Buffer for binary frames
try {
const msg = msgpack.decode(raw)
console.log(msg) // same structure as JSON envelope: { type, payload, id }
} catch (err) {
console.error('Invalid MessagePack frame')
ws.close(1003, 'Unsupported data')
}
})
// Send binary frame (pass Buffer directly — ws detects binary vs text)
function sendMsgpack(data: unknown) {
const buf = msgpack.encode(data)
ws.send(buf) // ws sends as binary frame automatically when given a Buffer
}
sendMsgpack({ type: 'connected', payload: { ts: Date.now() }, id: null })
})
// ── Browser client with MessagePack ───────────────────────────
// npm install msgpack-lite
import msgpack from 'msgpack-lite'
const ws = new WebSocket('wss://api.example.com/ws')
ws.binaryType = 'arraybuffer' // receive binary frames as ArrayBuffer (not Blob)
ws.onmessage = (event) => {
if (event.data instanceof ArrayBuffer) {
const msg = msgpack.decode(new Uint8Array(event.data))
handleMessage(msg)
}
}
function sendMsg(data: unknown) {
const buf = msgpack.encode(data)
ws.send(buf)
}
// ── CBOR alternative (cbor-x for better performance) ──────────
// npm install cbor-x
import { encode, decode } from 'cbor-x'
function sendCbor(ws: WebSocket, data: unknown) {
ws.send(encode(data))
}
function decodeCbor(raw: Buffer): unknown {
return decode(raw)
}
// CBOR supports typed arrays natively — useful for sensor/IoT data:
// { ts: new Date(), readings: new Float32Array([1.2, 3.4, 5.6]) }
// This encodes to CBOR with a proper date tag and typed array tag
// JSON would need: { "ts": "2026-05-19T00:00:00Z", "readings": [1.2, 3.4, 5.6] }
// ── When to use binary vs JSON text frames ─────────────────────
// Use JSON text frames when:
// - Message rate < 100/sec per connection
// - Human-readable debugging is important
// - Clients may include non-Node.js environments (IoT, legacy)
// Use MessagePack/CBOR binary frames when:
// - Message rate > 1000/sec (trading, gaming, real-time sensors)
// - Bandwidth is constrained (mobile clients, metered connections)
// - Payloads contain many repeated string keys (field names dominate size)
// Migration strategy: negotiate format in the handshake
// Client sends Upgrade request with header: Sec-WebSocket-Protocol: msgpack
// Server responds with the accepted subprotocol
// Both sides switch to binary encoding for that connectionFor IoT and gaming use cases where JSON field names dominate payload size (e.g., "temperature", "humidity" repeated thousands of times per minute), MessagePack's compact key encoding provides the most benefit. For general application WebSocket APIs (chat, notifications, collaboration), JSON text frames are simpler to debug and maintain — the size difference is typically under 1 KB per message and not worth the added complexity. Negotiate the encoding format at connection time using the Sec-WebSocket-Protocol header (e.g., msgpack vs json) so the server and client both know which format to use without embedding format metadata in every message. See the JSON compression guide for text-frame compression alternatives.
Key Terms
- text frame
- A WebSocket frame with opcode 0x1 carrying a UTF-8 encoded string payload. Text frames are the standard mechanism for transmitting JSON over WebSocket —
JSON.stringify()produces a UTF-8 string that maps directly to a text frame. The WebSocket protocol validates that text frame payloads are valid UTF-8 and will close the connection (code 1007) if invalid bytes are received. Browser clients receive text frame data as a JavaScript string inevent.data; Node.jswslibrary delivers them asBufferobjects that must be converted with.toString()beforeJSON.parse(). - ping-pong
- The WebSocket protocol-level heartbeat mechanism defined in RFC 6455. A ping frame (opcode 0x9) can carry up to 125 bytes of application data; the receiver must respond with a pong frame (opcode 0xA) carrying the same payload data. Browser clients respond to pings automatically — JavaScript applications cannot send protocol pings (only pongs in response). Node.js
wslibrary exposesws.ping()and thepongevent. Protocol ping-pong is distinct from application-level JSON heartbeat messages: protocol pings are handled at the WebSocket layer before application code sees them, while JSON heartbeats ({"type":"ping"}) are application messages processed like any other. - heartbeat
- A periodic signal sent over a WebSocket connection to verify that both ends are alive and the connection is still healthy. Heartbeats prevent proxy timeout disconnections (most load balancers drop idle connections after 60 seconds) and detect silently failed connections where the TCP layer did not surface the disconnection to the application. A 25-second heartbeat interval is the common recommendation — short enough to detect failures within one interval, long enough to avoid excessive overhead. Heartbeats can be implemented at the protocol level (WebSocket ping frames) or the application level (JSON messages with
"type": "ping"). Servers should track the timestamp of the last received pong/heartbeat and close connections that exceed the timeout. - envelope
- A standard JSON wrapper structure applied to every WebSocket message, providing metadata fields that enable routing, correlation, and versioning independently of the message content. A canonical envelope has:
type(string discriminant for dispatch),payload(message-specific data object),id(correlation string for request-response matching), and optionallyv(protocol version number). The envelope separates transport concerns (routing, correlation) from business data (the payload), enabling the same dispatch infrastructure to handle any message type without inspecting the payload. This pattern is used by Socket.IO, Phoenix Channels, AWS IoT MQTT, and most production WebSocket APIs. - fan-out
- The operation of distributing a single incoming JSON message to multiple WebSocket clients simultaneously — also called broadcasting. Local fan-out iterates over the server's connected client set and calls
send()on each. Multi-server fan-out requires a shared message bus (Redis Pub/Sub, Kafka) so that a message published to server A reaches clients connected to servers B and C. The JSON message should be serialized once before fan-out (not once per client) to minimize CPU overhead. Fan-out throughput is bounded by the number of clients, the serialization cost, and network bandwidth; for very large fan-out (100K+ clients), horizontal scaling with a Redis adapter or dedicated message broker is required. - backoff
- A reconnection strategy that increases the delay between successive reconnection attempts to avoid overwhelming a recovering server. Exponential backoff starts at a base delay (typically 1 second) and doubles on each failed attempt: 1s, 2s, 4s, 8s, 16s, 30s (capped). Adding jitter (randomizing the delay by ±50%) prevents the thundering herd problem where thousands of clients all reconnect simultaneously after a server restart. WebSocket backoff should reset to the base delay on successful reconnection. The reconnection attempt counter must be reset on a successful connection, and the reconnection loop must respect an
intentionallyClosedflag to stop reconnecting after a deliberate user logout or application shutdown.
FAQ
How do I send JSON over a WebSocket connection?
Sending JSON over WebSocket requires two operations: JSON.stringify(object) before calling ws.send() on the sender, and JSON.parse(event.data) inside the onmessage handler on the receiver. On the browser: ws.send(JSON.stringify({ type: "subscribe", payload: { channel: "prices" }, id: "req-1" })). Always wrap JSON.parse in a try/catch — malformed frames from buggy clients or network corruption will throw a SyntaxError. In Node.js with the ws library, message data arrives as a Buffer: convert it with raw.toString() before parsing. WebSocket has no built-in serialization protocol, so both ends must agree to use JSON text frames (or MessagePack binary frames) — there is no content negotiation unless you implement it via Sec-WebSocket-Protocol header.
What is a WebSocket JSON message envelope pattern?
The envelope pattern wraps every WebSocket message in a standard JSON structure with three fields: type (string discriminant, e.g., "subscribe", "price", "error"), payload (message-specific data object), and id (UUID or counter string for correlating server responses to client requests). The type field enables O(1) dispatch — the server routes messages via a lookup table (handlers[msg.type](msg)) rather than inspecting payload content to infer intent. A v (version) field supports protocol evolution. This pattern is used by Socket.IO, Phoenix Channels, and most production WebSocket APIs. TypeScript discriminated unions — type SubscribeMsg = { type: "subscribe"; payload: {...}; id: string } — provide compile-time safety for all message variants.
How do I handle WebSocket reconnection with JSON state sync?
Implement exponential backoff in the onclose handler: start at 1 second, double on each failure (2s, 4s, 8s, 16s), cap at 30 seconds. Add ±50% jitter to prevent thundering herd. On reconnect, send a JSON "resume" message with the last known sequence number or event ID so the server can replay missed events: { "type": "resume", "payload": { "lastSeq": 4821, "subscriptions": ["prices:BTC"] }, "id": "resume-1" }. Track active subscriptions in a client-side array and resend all "subscribe" messages after reconnect — most WebSocket servers discard per-connection state on disconnect. Reset the attempt counter to 0 on successful reconnection. Set an intentionallyClosed flag to prevent reconnection after deliberate logout. Wait for the server's "connected" JSON acknowledgment before resending subscriptions, not just the WebSocket open event.
What is the difference between Socket.IO and plain WebSocket JSON?
Plain WebSocket sends raw JSON strings as UTF-8 text frames with no additional protocol — the application layer is entirely custom. Socket.IO wraps WebSocket (with HTTP long-polling fallback) in the Engine.IO transport protocol, framing every message as 42["eventName",{...}] (4 = Engine.IO message, 2 = Socket.IO event, followed by a JSON array). Socket.IO adds: rooms (server-side groups for broadcasting JSON to subsets of clients), namespaces (logical channels over one TCP connection), acknowledgments (server calls a callback with a JSON response that the client receives), and automatic reconnection with backoff. Plain WebSocket is lighter (no overhead protocol), works with any WebSocket client, and is better for binary-heavy or high-frequency streams. Socket.IO is better when you need rooms, namespaces, reliable acknowledgment delivery, or HTTP fallback without building those features yourself.
How do I validate incoming WebSocket JSON messages?
Validate in two stages. Stage 1: wrap JSON.parse(raw.toString()) in a try/catch to reject syntactically invalid JSON — respond with an error JSON and close the connection after repeated failures. Stage 2: validate the parsed object with Zod using a discriminated union schema: const schema = z.discriminatedUnion('type', [SubscribeSchema, PingSchema]) — this validates structure, field types, and allowed values in O(1) based on the type discriminant. Use safeParse() (not parse()) to get a result object rather than throwing. Include the original message id in error responses so the client can correlate the error to the failed request. Close the connection (code 1008) after a configurable number of violations. Never reflect the raw invalid message content back to the client.
How do I broadcast JSON to multiple WebSocket clients?
With the Node.js ws library, serialize the JSON string once before the loop, then iterate over wss.clients and call send() only on clients with readyState === WebSocket.OPEN. For room-based broadcasting, maintain a Map<string, Set<WebSocket>> of room names to client sets and iterate only the relevant subset. For multi-server broadcasting (horizontal scaling), use Redis Pub/Sub: one server publishes the JSON message to a Redis channel, all servers subscribe and forward to their local clients in that room. Socket.IO's io.to(room).emit(event, data) handles room broadcasting and multi-server fan-out via its adapter system (Redis adapter: @socket.io/redis-adapter). Always check readyState before sending — clients in CLOSING or CLOSED state will throw synchronously on send().
Why should I use MessagePack instead of JSON for WebSockets?
MessagePack encodes equivalent JSON 20–50% smaller in binary, using WebSocket binary frames instead of text frames. A JSON object with four numeric fields is typically 50–65 bytes as JSON text but 28–35 bytes as MessagePack — a 40–55% reduction. This matters for high-frequency WebSocket streams (trading feeds, gaming state updates, IoT sensors) sending thousands of messages per second, where bandwidth and serialization CPU become bottlenecks. In Node.js, use msgpack-lite: ws.send(msgpack.encode(data)) on the server; on the browser, set ws.binaryType = 'arraybuffer' and decode with msgpack.decode(new Uint8Array(event.data)). The trade-off: MessagePack is not human-readable (harder to debug in DevTools), requires a library on both ends, and adds code complexity. For most application WebSocket APIs (under 100 messages/second per connection), JSON text frames are simpler and the size difference is negligible.
How do I authenticate WebSocket connections with JSON tokens?
The recommended pattern is to send a JSON "auth" message as the first message after the WebSocket open event, containing a JWT in the payload: ws.onopen = () => ws.send(JSON.stringify({ type: "auth", payload: { token: jwt }, id: "auth-1" })). The server validates the JWT in the message handler, sets a per-connection isAuthenticated flag, and closes the connection (code 1008) if the first message is not an auth message or if the token is invalid. Do NOT pass tokens as WebSocket URL query parameters in production — they appear in server logs and browser history. The browser WebSocket API does not support custom request headers, so URL query parameters are the only alternative to first-message auth, but they require careful log scrubbing. For JWT expiry during long-lived connections, send a JSON "refresh" message with a new token before the current token expires, and rotate the per-connection auth state without disconnecting.
Further reading and primary sources
- RFC 6455: The WebSocket Protocol — IETF standard defining WebSocket framing, ping/pong, and the opening handshake
- ws: Node.js WebSocket Library — Official ws library documentation, API reference, and usage examples for Node.js
- Socket.IO Documentation — Socket.IO v4 server/client API, namespaces, rooms, acknowledgments, and adapters
- MessagePack Specification — MessagePack binary serialization format specification and language implementations
- Zod Documentation — TypeScript-first schema validation library used for WebSocket message validation