Wire protocols

The channel moves bytes; you design the messages

An RTCDataChannel gives you one guarantee: bytes sent on one side arrive as bytes on the other. It does not know what those bytes mean. It does not split a stream into messages for you on a reliable-ordered channel unless you tell it where one message ends and the next begins. It does not validate, route, or version anything.

That layer is yours. A wire protocol is the contract two peers agree on for the shape of every message: how it is encoded, how the receiver tells one message type from another, and how both sides stay compatible as the code changes.

Get the protocol right and every game and feature on top of it stays small. Get it wrong and you debug truncated frames, silent type mismatches, and version skew for the life of the project.

This article covers the full design space:

  • text/JSON versus binary payloads, and when each fits
  • the type-discriminator pattern and a dispatcher
  • binary framing with length prefixes and DataView
  • schema and versioning so old and new peers interoperate
  • namespacing control traffic against application traffic
  • request/response correlation with ids
  • idempotency and ordering tied to the channel's reliability mode
  • serialization tradeoffs and their size/latency impact
Applicationgame state · chat · file chunks Dispatchtype discriminator → handler EncodingJSON or binary codec Framinglength prefix · message boundary RTCDataChannelbyte pipe SCTP / DTLS / UDPtransport THIS ARTICLE

The repo ships a JSON protocol in src/salon/protocol.js. It is small on purpose, and most pages here build on it. The binary sections show what changes when message size or rate makes JSON too expensive.

Part 1: Choosing an encoding

Text and JSON

JSON is the default for a reason. Every browser ships JSON.stringify and JSON.parse. The output is human-readable, so you can log a message and see exactly what crossed the wire. Adding a field never breaks an old parser. For control traffic, chat, lobby state, and turn-based games, JSON is the right call and you should not reach past it.

channel.send(JSON.stringify({ type: 'chat', text: 'gg' }));
// receiver:
const msg = JSON.parse(event.data); // { type: 'chat', text: 'gg' }

The cost is size and parse time. A single integer becomes its decimal digits plus quotes plus a key name. {"type":"pos","x":128,"y":64} is 27 bytes to carry two numbers. At 60 messages per second that adds up, and JSON.parse allocates a fresh object every call.

JSON also can't carry every value type. undefined and functions vanish. NaN and Infinity serialize to null. A BigInt throws. Dates become strings and don't come back as dates. Binary data has no JSON representation at all: you'd have to base64 it, which the hot-path rule below forbids. For the message shapes a game actually sends (numbers, short strings, booleans, small arrays), none of this bites. Know the edges before you trust JSON with an unusual value.

Binary

A binary encoding packs values into raw bytes with no keys and no delimiters. The two numbers above fit in 4 bytes as two Int16. The receiver reads them back by position. No string parsing, no per-message object churn beyond what you choose to allocate.

The cost is that you write and maintain the codec by hand, and a wrong offset produces garbage rather than an error. Binary is unreadable in a log without a decoder.

When each fits

Use Encoding Why
Chat, lobby, control JSON Rare, readability and flexibility win
Turn-based game moves JSON Low rate, size irrelevant
Per-frame position updates (60 Hz) Binary Size and parse cost dominate
File transfer chunks Binary (raw ArrayBuffer) Bytes are already bytes; never base64 them into JSON
Mixed protocol JSON for control, binary for the hot path Tag binary frames so the receiver knows which decoder to run

A practical rule: start in JSON. Move a single message type to binary only when you measure that it matters. See reliability and multiplayer for the rate and latency budgets that force that decision.

Never base64-encode binary into a JSON string for a hot path. Base64 inflates the payload by a third and adds an encode/decode step on each side. If the data is already bytes, send the bytes.

Part 2: The type-discriminator pattern

One channel, many message kinds

A data channel carries a stream of unrelated messages: a position update, then a chat line, then a score change. The receiver needs to route each one to the code that handles it. The standard technique is a type discriminator, a field on every message that names its kind.

The repo uses type, with t as a short alias for the hot path where the key name's own bytes matter.

{ type: 'chat', text: 'gg' }      // readable, control-rate
{ t: 'pos', x: 128, y: 64 }        // short key, sent often

Every message is a plain JSON object with exactly one discriminator. The receiver reads it and dispatches.

The repo's send/broadcast/dispatcher

src/salon/protocol.js is the whole JSON layer. Three functions, each doing one thing.

import { emit } from './bus.js';

export function send(channel, msg) {
  if (channel?.readyState !== 'open') return false;
  channel.send(JSON.stringify(msg));
  emit('wire:send', { label: channel.label, msg });
  return true;
}

export function broadcast(channels, msg) {
  let n = 0;
  for (const ch of channels) {
    if (send(ch, msg)) n++;
  }
  emit('wire:broadcast', { count: n, msg });
  return n;
}

export function dispatcher(handlers) {
  return (msg, channel) => {
    const key = msg?.type ?? msg?.t;
    const fn = key !== undefined ? handlers[key] : undefined;
    if (fn) fn(msg, channel);
  };
}

Three details carry the design.

The readyState guard. send returns false and does nothing if the channel is not 'open'. A channel can be connecting, open, closing, or closed. Calling .send() on any state other than open throws an InvalidStateError. The guard turns a thrown exception into a return value you can check. broadcast relies on it to skip dead channels in a peer set and count only the ones that took the message.

The type ?? t read. dispatcher reads msg?.type, and if that is undefined, falls back to msg?.t. One dispatcher handles both the readable and the short form. The ?. on msg means a malformed or null message produces undefined, not a crash.

Unknown types are dropped, not errors. If no handler matches the key, dispatcher does nothing. This is deliberate and it is what makes versioning work: a new peer can send a message type an old peer has never heard of, and the old peer ignores it instead of throwing.

Wiring a dispatcher

You build a { type: handler } map once and hand the result to the channel's onmessage.

import { send, dispatcher } from './salon/protocol.js';

const onMessage = dispatcher({
  chat:  (msg) => addChatLine(msg.text),
  pos:   (msg) => moveGuest(msg.x, msg.y),
  score: (msg) => setScore(msg.value),
});

channel.onmessage = (event) => onMessage(JSON.parse(event.data), channel);

// sending stays symmetric:
send(channel, { type: 'chat', text: 'gg' });
send(channel, { t: 'pos', x: 128, y: 64 });

The handler receives the parsed message and the channel it arrived on. The channel argument lets a handler reply on the same connection, which matters for the host in a multi-guest game where each channel is a different player.

The dispatcher is a flat switch with no fallthrough and no ordering. Adding a message type means adding one entry to the map. Removing one means deleting an entry; messages of that type then fall through to the silent drop.

Why a map and not a switch

A hand-written switch (msg.type) does the same routing. The map form has three edges over it. You can build the handler set at runtime: a game registers its own types when it loads and tears them down when it unloads, without editing a central switch. You can compose maps with object spread: dispatcher({ ...baseHandlers, ...gameHandlers }) merges a shared set with a per-game set. And the map is data, so a test can assert exactly which types a peer handles by reading the keys. A switch buries that in control flow.

The tradeoff is one property lookup per message instead of a jump table. At any message rate a data channel sustains, that lookup is free.

Validating before dispatch

dispatcher routes on the discriminator and trusts the rest of the message. A peer is remote code you don't control, so a malformed or hostile message can carry a wrong-typed field. The dispatcher won't catch that; it hands msg straight to your handler. Validate inside the handler, or wrap the dispatcher with a check, before you act on a field:

const onMessage = dispatcher({
  pos: (msg) => {
    if (typeof msg.x !== 'number' || typeof msg.y !== 'number') return;
    moveGuest(msg.x, msg.y);
  },
});

For a casual peer-to-peer game between two people who chose to connect, light validation is enough: reject the obviously wrong, ignore the rest. The point is that the wire layer gives you no guarantees about message contents; only the discriminator is checked, and only well enough to route.

Part 3: Binary framing

When you move to binary you take on two jobs JSON did for free: marking message boundaries, and encoding values by position instead of by key.

Message boundaries and length prefixes

On an unreliable or unordered data channel, SCTP preserves message boundaries: each .send() of an ArrayBuffer arrives as one message event with that exact buffer. You do not need a length prefix; one send is one message.

The trap is the reliable, ordered channel used like a stream, or any path where you concatenate several logical messages into one buffer to cut send overhead. There the receiver gets a run of bytes and must know where each message stops. The fix is a length prefix: write the byte length of the payload first, then the payload.

len=122 bytes payload12 bytes len=52 bytes payload5 bytes read 2 → N read N → message repeat
// Concatenate framed messages into one buffer, then read them back.
function frame(payload) {            // payload: Uint8Array
  const out = new Uint8Array(2 + payload.length);
  const view = new DataView(out.buffer);
  view.setUint16(0, payload.length, false); // length prefix, big-endian
  out.set(payload, 2);
  return out;
}

function* readFrames(buffer) {        // buffer: ArrayBuffer
  const view = new DataView(buffer);
  let offset = 0;
  while (offset + 2 <= buffer.byteLength) {
    const len = view.getUint16(offset, false);
    offset += 2;
    if (offset + len > buffer.byteLength) break; // incomplete tail
    yield new Uint8Array(buffer, offset, len);
    offset += len;
  }
}

A Uint16 prefix caps a single message at 65535 bytes. Use setUint32 if you need more. Pick a prefix width once and document it; the reader must use the same width.

DataView, ArrayBuffer, and endianness

ArrayBuffer is a fixed block of bytes. You do not read or write it directly; you go through a view. DataView is the view that lets you read and write typed values at any byte offset, with an explicit endianness per call.

const buf = new ArrayBuffer(8);
const view = new DataView(buf);
view.setUint16(0, 65535, false); // bytes 0-1, big-endian
view.setInt16(2, -1, false);     // bytes 2-3
view.setFloat32(4, 3.5, false);  // bytes 4-7

Endianness is the byte order of multi-byte numbers. Big-endian writes the most significant byte first; little-endian writes it last. The second argument to every DataView getter and setter is littleEndian. It defaults to false (big-endian, the network convention). The only rule that matters: writer and reader must agree. Pass the flag explicitly on both sides so the agreement is visible in the code and a refactor can't silently flip it.

getUint16/setUint16 respect the flag. getUint8/setUint8 do not take one: a single byte has no order.

A binary codec with a type tag

Binary messages still need a discriminator. Reserve the first byte for a type tag, then lay out fields by position. Fixed-width fields are read at known offsets. Variable-width fields (a string, a chunk) get their own length prefix inside the message.

const T_POS = 1;   // host -> guest: position
const T_CHAT = 2;  // text, variable length

function encodePos(x, y) {
  const buf = new ArrayBuffer(5);    // 1 tag + 2 + 2
  const view = new DataView(buf);
  view.setUint8(0, T_POS);
  view.setInt16(1, x, false);
  view.setInt16(3, y, false);
  return buf;
}

function encodeChat(text) {
  const bytes = new TextEncoder().encode(text);   // UTF-8
  const buf = new ArrayBuffer(3 + bytes.length);   // 1 tag + 2 len + body
  const view = new DataView(buf);
  view.setUint8(0, T_CHAT);
  view.setUint16(1, bytes.length, false);
  new Uint8Array(buf, 3).set(bytes);
  return buf;
}

function decode(buf) {
  const view = new DataView(buf);
  switch (view.getUint8(0)) {
    case T_POS:
      return { t: 'pos', x: view.getInt16(1, false), y: view.getInt16(3, false) };
    case T_CHAT: {
      const len = view.getUint16(1, false);
      const text = new TextDecoder().decode(new Uint8Array(buf, 3, len));
      return { t: 'chat', text };
    }
    default:
      return null; // unknown tag: drop, mirrors the JSON dispatcher
  }
}

decode produces the same { t, ... } shape the JSON dispatcher already routes. That means a binary path can feed the same dispatcher map; only the parse step at the channel's onmessage changes from JSON.parse to decode. The receiver must read the channel's event.data as an ArrayBuffer; set channel.binaryType = 'arraybuffer' once, or it defaults to Blob and you'd parse asynchronously.

channel.binaryType = 'arraybuffer';
channel.onmessage = (event) => onMessage(decode(event.data), channel);

Fixed versus variable fields

Field kind Layout Read Tradeoff
Fixed (int, float, bool) Known offset, known width Read at offset Cheapest; offsets are positional and brittle to reorder
Variable (string, blob) Length prefix then body Read length, then that many bytes Flexible; one extra read and a size cap to pick
Optional A flags byte, then present fields in order Branch on flag bits Saves bytes; the layout is no longer constant-offset

Keep all fixed fields before any variable field. Then every fixed field sits at a constant offset and the variable section starts at a known position. Mixing them forces the reader to walk the message to find later fixed fields, which defeats the point of binary.

Packing several values into one byte

Booleans and small enums waste a whole byte each if you give them their own field. Pack them into a flags byte: one bit per boolean, a few bits for a small enum. The reader masks the bits back out.

const F_ALIVE  = 1 << 0;
const F_SHIELD = 1 << 1;
const F_FACING = 1 << 2; // 0 = left, 1 = right

function packFlags({ alive, shield, facingRight }) {
  return (alive ? F_ALIVE : 0) | (shield ? F_SHIELD : 0) | (facingRight ? F_FACING : 0);
}

function unpackFlags(byte) {
  return {
    alive: (byte & F_ALIVE) !== 0,
    shield: (byte & F_SHIELD) !== 0,
    facingRight: (byte & F_FACING) !== 0,
  };
}

Three booleans now cost one byte instead of three. This matters on a per-frame update where every byte ships 60 times a second. It's premature anywhere else: pack bits only on a message you've measured as hot.

Numeric range and precision

Binary forces you to pick a width per number, which means picking a range. An Int16 holds −32768 to 32767. A coordinate beyond that wraps to a wrong value with no error. Pick the width from the real range: screen coordinates fit Int16, a frame counter that runs for hours wants Uint32, a normalized float wants Float32 (or quantize it to an Int16 if you can spend one part in 32000 of precision). JSON never made you choose; it printed whatever digits the number had. Binary trades that freedom for size, and the cost of getting the range wrong is silent corruption.

Part 4: Schema and versioning

Two peers can run different builds of the code. One player updates and reconnects to a friend who hasn't. The protocol has to survive that.

Additive changes are safe in JSON

Because dispatcher drops unknown types and JSON.parse ignores nothing, JSON tolerates additive change with no version field at all:

  • New message type. Old peer has no handler for it, drops it. New peer sends it only to peers it knows support it, or accepts that old peers ignore it.
  • New field on an existing type. Old peer reads the fields it knows and never looks at the new one. New peer reads the new field when present, falls back when absent.

What breaks compatibility: renaming a field, changing a field's type or units, repurposing a type name, or removing a handler other peers still send to. None of those are additive. Treat them as a new protocol version.

A version handshake

Send a version on connect so each side knows what the other speaks. Negotiate down to the lower of the two.

const PROTOCOL_VERSION = 3;

send(channel, { type: 'hello', version: PROTOCOL_VERSION });

const onMessage = dispatcher({
  hello: (msg, ch) => {
    const agreed = Math.min(msg.version, PROTOCOL_VERSION);
    setProtocolVersion(ch, agreed); // gate features on `agreed`
  },
  // ...
});

A handler then checks the agreed version before using a field that only exists from version N up. The peer-discovery handshake that brings the channel up in the first place is covered in connecting/protocols; the version exchange here runs after the channel is open.

Binary versioning is stricter

Binary has no field names, so a new field shifts every offset after it. Old decoders read the wrong bytes. Two ways to stay compatible:

  • Append only. Add new fields at the end of a message. An old decoder reads the prefix it understands and stops; a new decoder reads further. This works only if the old decoder bounds its reads by the buffer length instead of assuming a fixed size.
  • New tag. Define a new type tag for the new layout and keep the old one. Senders emit the tag the receiver's agreed version supports.

Put the protocol version in the byte stream too (a first byte on the connection's hello frame, or a tag value) so a binary peer can negotiate the same way.

Part 5: Namespacing control and application traffic

A hub or lobby layer sends its own messages over the same channel the game uses: "switch to this game," "I accept," "cancel." If hub messages used type:/t: they could collide with a game that happens to use the same type name. Two layers, one channel, one namespace: that's a bug waiting on a name clash.

The repo's c: convention

The hub uses a different discriminator key, c:, for its control messages.

{ c: 'propose', game: 'pong' }
{ c: 'accept' }
{ c: 'cancel' }

A game's dispatcher reads type ?? t, so a { c: ... } message has no key it recognizes and is dropped. The hub runs its own router keyed on c:, which ignores any type/t game message. The two namespaces share the channel and never see each other's traffic. No game type name can ever collide with a control name, because they live in different keys.

// game side, only sees type/t messages
const gameDispatch = dispatcher({ pos: ..., chat: ... });

// hub side, only sees c: messages
function hubRouter(msg, ch) {
  switch (msg?.c) {
    case 'propose': showProposal(msg.game, ch); break;
    case 'accept':  loadGame(ch); break;
    case 'cancel':  dismiss(ch); break;
  }
}

channel.onmessage = (event) => {
  const msg = JSON.parse(event.data);
  hubRouter(msg, channel);   // acts on c:, ignores type/t
  gameDispatch(msg, channel); // acts on type/t, ignores c:
};

This lets two connected peers switch games without renegotiating WebRTC: the control messages ride the live channel that gameplay already uses.

Separate channels as the alternative

The other approach is one channel per concern: a control channel and a game channel, opened separately on the same peer connection. Each carries one kind of traffic, so no namespacing is needed.

Shared channel + namespace Separate channels
Setup One channel, two key conventions One channel per concern, each negotiated
Collision risk None if keys differ (c: vs type/t) None by construction
Reliability tuning All traffic shares one mode Control reliable+ordered, game unreliable: tune each
Ordering across kinds Control and game interleave in one ordered stream Independent; no cross-channel ordering
Overhead Lowest One extra channel to open and track

Separate channels win when control and game want different reliability modes: control as reliable-and-ordered, gameplay as unreliable-and-unordered. You can't set that per-message on one channel; it's a channel-level property. When both want the same mode, the shared-channel namespace is simpler and the repo uses it.

Part 6: Request/response correlation

Most game traffic is fire-and-forget: send a position, send a move, never expect a reply. Some interactions are request/response: "do you have this file chunk?" → "here it is." When several requests are in flight, the replies can return in any order on an unordered channel, or just be hard to match. Tag each request with an id and echo it in the reply.

let nextId = 1;
const pending = new Map();

function request(channel, msg) {
  const id = nextId++;
  send(channel, { ...msg, id });
  return new Promise((resolve) => pending.set(id, resolve));
}

// in the dispatcher, on the reply type:
function onReply(msg) {
  const resolve = pending.get(msg.id);
  if (resolve) { pending.delete(msg.id); resolve(msg); }
}

// usage:
const result = await request(channel, { type: 'chunk-req', index: 7 });

The responder copies the id from request to reply without interpreting it. The requester matches replies to promises by id. Set a timeout that rejects and deletes the pending entry, or a dropped message on an unreliable channel leaks a promise that never settles.

Keep ids per-connection. They only need to be unique among the requests one peer has outstanding, so a monotonic counter is enough, no need for a UUID.

Part 7: Idempotency and ordering

The channel's reliability mode decides which assumptions hold. You configure it when you create the channel (ordered, maxRetransmits, maxPacketLifeTime); reliability covers the modes in full. The protocol must match the mode you chose.

Channel mode Delivery Order Protocol must assume
Reliable + ordered (default) Every message arrives In send order Nothing extra; a stream
Reliable + unordered Every message arrives Any order Messages may arrive out of order
Unreliable + ordered May drop In order (of those that arrive) Gaps; later state can precede recovery
Unreliable + unordered May drop Any order Gaps and reordering both

Two protocol-level defenses follow from the unreliable rows.

Idempotency. A message you might send twice, or that might arrive after a newer one, should be safe to apply more than once and safe to apply stale. Prefer absolute state over deltas on an unreliable channel: send "position is (128, 64)," not "move right by 4." Applying an absolute position twice is harmless. Applying a delta twice doubles the move. Applying a stale delta corrupts state permanently; applying a stale absolute is corrected by the next update.

Sequence numbers for drop-stale. When you need deltas, or must reject out-of-order updates, stamp each message with an increasing sequence number and ignore any whose number is not greater than the last applied.

let lastSeq = 0;

function onUpdate(msg) {
  if (msg.seq <= lastSeq) return; // stale or duplicate: drop
  lastSeq = msg.seq;
  applyState(msg);
}

On a reliable-ordered channel you need none of this: order and delivery are guaranteed. Adding sequence checks there is wasted bytes. Match the protocol's defenses to the channel mode you actually configured, not to a worst case you aren't running.

Part 8: Serialization tradeoffs

The encoding choice has measurable cost. Concrete numbers for a position update carrying two 16-bit integers:

Encoding Wire bytes Per-message work Readable
JSON.stringify({type:'pos',x,y}) ~27 String build + parse, object alloc Yes
JSON.stringify({t:'pos',x,y}) ~24 Same, shorter key Yes
Binary, 1 tag + 2×Int16 5 Offset writes/reads, no string work No

The binary form is roughly a fifth of the wire size and skips string parsing entirely. At low rates that difference is invisible and JSON's readability wins. At 60 Hz across a session it is the difference between a tight hot path and a parser the GC notices.

A middle ground keeps the discriminator in JSON for routing clarity while sending the heavy payload as raw bytes, but that means two parse steps and a tag scheme anyway, so it rarely beats committing one type to full binary.

Guidance:

  • Default to JSON. Use the t short key for any type you send more than a few times a second.
  • Profile before going binary. Measure message rate and JSON.parse time under load, not in the abstract.
  • When you go binary, convert one type (the hot one) and leave everything else JSON. The dispatcher routes both because decode returns the same { t, ... } shape.
  • Never base64 bytes into JSON for the hot path.

Recap

  • The channel moves bytes. The message format is your protocol, written by hand.
  • JSON is the default: readable, additive-safe, zero setup. Use it for control, chat, and turn-based traffic.
  • Every message carries a discriminator: type, or t for the hot path. dispatcher({ type: handler }) routes on it and drops unknown types silently.
  • send and broadcast guard on readyState === 'open' and return whether the send happened.
  • Binary cuts size and parse cost. Frame with a length prefix when boundaries aren't preserved; encode by position with DataView; agree on endianness explicitly.
  • Keep additive changes additive so old and new peers interoperate. Negotiate a version on connect for anything that isn't additive. Binary versioning means append-only fields or new tags.
  • Namespace control against application traffic (the hub's c: key versus game type/t) or use separate channels when the two want different reliability modes.
  • Correlate requests and replies with per-connection ids. Match the protocol's idempotency and ordering defenses to the channel's reliability mode.

Going further

  • RTCDataChannel: the byte pipe this protocol rides on, and its binaryType and lifecycle.
  • Reliability: the ordered/maxRetransmits/maxPacketLifeTime modes that decide which protocol defenses you need.
  • Multiplayer: authoritative-host and hidden-state patterns built on top of these messages.
  • Connecting / protocols: the handshake that opens the channel before any wire message flows.

Troubleshooting

Symptom Likely cause Fix
InvalidStateError on send Channel not open yet or already closed Use send()'s guard; check its return value
Messages silently ignored Discriminator mismatch: sent type, dispatcher keyed on something else, or sent c: to a type router Align the key; remember dispatcher reads type ?? t only
Binary message arrives as a Blob binaryType left at default Set channel.binaryType = 'arraybuffer' before reading
Garbage values from a binary decode Endianness or offset mismatch between encoder and decoder Pass littleEndian explicitly on both sides; verify field offsets
Truncated or merged binary messages Reading a concatenated buffer without length prefixes Frame with a length prefix; read length then payload
Old peer crashes on a new field Non-additive change (rename/retype) shipped without a version bump Negotiate a version; gate the field on the agreed version
Replies matched to the wrong request No correlation id, replies returning out of order Tag requests with an id, echo it in the reply
State drifts over a lossy channel Applying deltas that dropped or arrived stale Send absolute state, or add sequence numbers and drop-stale
Promise from request() never settles Reply dropped on an unreliable channel Add a timeout that rejects and clears the pending entry