WebSocket Real-Time Simulation Sync
Add multi-client real-time sync to a browser physics simulation using the WebSocket API — no Socket.io, no frameworks. Covers message framing, heartbeat/reconnect, delta compression, and a minimal room-based server in Node.js.
1. WebSocket vs HTTP
HTTP is request-response — the server can only send data after the client requests it. WebSocket establishes a persistent, full-duplex TCP channel with a one-time HTTP upgrade handshake. After the handshake, both sides can send frames at any time with minimal overhead (2–10 byte header vs ~500 bytes for HTTP).
- Latency: HTTP polling: 200–500 ms round-trip minimum. WebSocket: limited only by network RTT (~20–50 ms on LAN).
- Bandwidth: WebSocket frames add 2–10 bytes of overhead vs ~500 bytes for HTTP headers per message.
- Use WebSocket when: low latency matters (live physics, multiplayer, collaborative drawing, live telemetry).
- Use HTTP/SSE when: server-push only (notifications, live feed), simpler setup, better CDN caching.
2. Connection and Handshake
// client.js — open connection const ws = new WebSocket('wss://example.com/sim?room=abc123'); ws.addEventListener('open', () => { console.log('[WS] connected'); send({ type: 'join', room: 'abc123', name: 'Player1' }); }); ws.addEventListener('message', (event) => { const msg = JSON.parse(event.data); handleMessage(msg); }); ws.addEventListener('close', (event) => { console.log(`[WS] closed: ${event.code} ${event.reason}`); scheduleReconnect(); }); ws.addEventListener('error', (err) => { console.error('[WS] error', err); // 'error' is always followed by 'close' — handle reconnect there }); function send(obj) { if (ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify(obj)); } }
0 CONNECTING,
1 OPEN, 2 CLOSING, 3 CLOSED.
Always check readyState === WebSocket.OPEN before sending
— calling send() while connecting or closed throws an
exception.
3. Message Framing
Define a typed message envelope. Even with JSON text frames, a type discriminator makes routing easy:
// Message types — define as constants const MSG = { JOIN: 'join', LEAVE: 'leave', STATE: 'state', // full snapshot DELTA: 'delta', // incremental update PING: 'ping', PONG: 'pong', INPUT: 'input', // client → server: user action }; // Example STATE message payload { type: 'state', tick: 1234, // monotonic simulation tick t: 123456.78, // simulation time (ms) particles: [ { id: 0, x: 1.23, y: 4.56, vx: 0.1, vy: -0.2 }, // ... up to N particles ] } // For binary efficiency, use MessagePack or Protobuf // For simulations with <500 particles, JSON is fine (~20KB/frame)
For high-frequency updates (>10 msg/s with many entities), switch to
binary frames with a Float32Array packed
message. WebSocket's binaryType = 'arraybuffer' allows
this at a fraction of the bandwidth.
4. Heartbeat and Reconnect
// Heartbeat — detect silent disconnects const PING_INTERVAL = 15_000; // 15 s const PONG_TIMEOUT = 5_000; // if no pong in 5 s → reconnect let pingTimer = null, pongTimer = null; function startHeartbeat() { pingTimer = setInterval(() => { send({ type: MSG.PING, t: Date.now() }); pongTimer = setTimeout(() => { console.warn('[WS] pong timeout — reconnecting'); ws.close(); }, PONG_TIMEOUT); }, PING_INTERVAL); } function handleMessage(msg) { if (msg.type === MSG.PONG) { clearTimeout(pongTimer); latency = Date.now() - msg.t; return; } // ... handle other message types } // Exponential back-off reconnect let reconnectDelay = 1000; function scheduleReconnect() { clearInterval(pingTimer); console.log(`[WS] reconnecting in ${reconnectDelay}ms`); setTimeout(() => { reconnectDelay = Math.min(reconnectDelay * 2, 30_000); connect(); }, reconnectDelay); } ws.addEventListener('open', () => { reconnectDelay = 1000; // reset on successful connect startHeartbeat(); });
5. Sim Loop Integration
Don't send a new state every animation frame — browsers render at 60+ fps but network messages at 10–20 Hz is sufficient for most simulations. Decouple the render loop from the network send rate:
const SEND_RATE = 20; // Hz const SEND_INTERVAL = 1000 / SEND_RATE; // 50 ms let lastSendTime = 0; function simLoop(now) { requestAnimationFrame(simLoop); // 1. Step physics world.step(dt); // 2. Render renderer.render(scene, camera); // 3. Network send (throttled) if (isAuthority && now - lastSendTime >= SEND_INTERVAL) { lastSendTime = now; sendState(); } } requestAnimationFrame(simLoop); // On non-authority clients: receive state and interpolate let remoteState = null, prevState = null; function handleMessage(msg) { if (msg.type === MSG.STATE || msg.type === MSG.DELTA) { prevState = remoteState; remoteState = msg; remoteTime = performance.now(); } } // Interpolate between prevState and remoteState based on age of message function interpolateParticles() { const age = performance.now() - remoteTime; const alpha = Math.min(age / SEND_INTERVAL, 1); // lerp(prevState.particles[i].x, remoteState.particles[i].x, alpha) }
6. Delta Compression
Instead of sending the full state every 50 ms, send only changed values. For a particle simulation, most particles move every tick, but many properties (mass, type, colour) are static:
let prevSentState = null; const EPSILON = 0.001; function sendState() { const deltas = []; for (const p of particles) { const prev = prevSentState?.[p.id]; if (!prev || Math.abs(p.x - prev.x) > EPSILON || Math.abs(p.y - prev.y) > EPSILON) { deltas.push({ id: p.id, x: +p.x.toFixed(3), y: +p.y.toFixed(3), vx: +p.vx.toFixed(3), vy: +p.vy.toFixed(3) }); } } if (deltas.length > 0) { send({ type: MSG.DELTA, tick: simTick, d: deltas }); } // Send full snapshot every 2 s for new joiners / correction if (simTick % (20 * 2) === 0) sendFullSnapshot(); prevSentState = Object.fromEntries(particles.map(p => [p.id, {...p}])); }
Float32Array directly and use
ws.send(buffer.buffer). This is 3–4× smaller than JSON
for the same data and uses zero-copy on most engines.
7. Minimal Node.js Server
Using the ws npm package — the lightest production
WebSocket library (npm install ws):
// server.js (Node.js) const { WebSocketServer } = require('ws'); const wss = new WebSocketServer({ port: 8080 }); const rooms = new Map(); // roomId → Set<WebSocket> wss.on('connection', (ws) => { let clientRoom = null; ws.on('message', (raw) => { let msg; try { msg = JSON.parse(raw); } catch { ws.close(1008, 'bad json'); return; } if (msg.type === 'join') { const room = sanitiseRoomId(msg.room); if (!rooms.has(room)) rooms.set(room, new Set()); rooms.get(room).add(ws); clientRoom = room; ws.send(JSON.stringify({ type: 'joined', room, peers: rooms.get(room).size })); return; } if (msg.type === 'ping') { ws.send(JSON.stringify({ type: 'pong', t: msg.t })); return; } // Broadcast to room (except sender) if (clientRoom && rooms.has(clientRoom)) { const peers = rooms.get(clientRoom); for (const peer of peers) { if (peer !== ws && peer.readyState === 1) peer.send(raw); } } }); ws.on('close', () => { if (clientRoom) rooms.get(clientRoom)?.delete(ws); }); }); function sanitiseRoomId(id) { // Only allow alphanumeric room names (prevent path injection) return String(id).replace(/[^a-zA-Z0-9_-]/g, '').slice(0, 32); }
This server is a pure relay: it broadcasts all messages from one client to all others in the same room. The simulation authority stays on one client (or runs authoritatively on the server for competitive scenarios).
8. Rooms and Authority
In a browser simulation, one client is the authority — it runs the physics and broadcasts state. Others receive and interpolate. When the authority disconnects, elect a new one:
// Client — authority election on host disconnect function handleMessage(msg) { if (msg.type === 'leave' && msg.id === authorityId) { // Smallest-id client becomes new authority isAuthority = (myId === Math.min(...peerIds)); if (isAuthority) console.log('[WS] I am now the authority'); } }
✅ Validate all incoming message fields (type, room, particles) before use — never trust client data
✅ Rate-limit sends per connection on the server (e.g. max 30 msg/s)
✅ Use
wss:// (TLS) in production — plain
ws:// is blocked on HTTPS pages✅ Implement maximum room size to prevent DoS
✅ Never echo
msg.room back to the client without
sanitising (reflected in server code above)