🔧 Tutorial · Networking
📅 March 2026⏱ ~18 min🟡 Intermediate🟢 Pure JS · No libraries

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).

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));
  }
}
ReadyState values: 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}]));
}
Binary framing: For dense particle data (>1000 particles), pack 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');
  }
}
Checklist for production usage:
✅ 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)