Skip to content

Framing Messages

There is a subtle gift the WebSocket protocol gives you, and a subtle trap right next to it. The gift is that messages arrive whole. The trap is believing that whole means meaningful. This lesson is about the gap between the two.

Raw TCP has no messages — WebSocket does

Section titled “Raw TCP has no messages — WebSocket does”

If you have ever written code against a raw TCP socket, you know the pain: TCP is a byte stream. You might call send('{"a":1}') once on one side, but the other side could receive '{"a' then ':1}', or both in one chunk glued to the next message. There are no boundaries. You have to invent framing yourself — length prefixes, delimiters, something — to know where one message ends and the next begins.

WebSocket already solved that for you. The protocol frames the stream at the wire level, so onmessage fires once per message you sent, with the whole payload intact. One send on one end becomes exactly one onmessage on the other.

flowchart TB
  subgraph tcp["Raw TCP — a byte stream"]
    direction LR
    t1["send A · send B"] --> t2["recv: A+par<br/>recv: t of B"]
    t2 --> t3["YOU must reframe"]
  end
  subgraph wsf["WebSocket — discrete messages"]
    direction LR
    w1["send A · send B"] --> w2["onmessage: A<br/>onmessage: B"]
    w2 --> w3["boundaries already correct"]
  end
Raw TCP delivers a stream; WebSocket delivers discrete messages

So the low-level framing problem — where does a message end — is gone. Wonderful. But that only tells you the bytes are grouped correctly. It says nothing about what those grouped bytes mean.

Here is the trap. Because messages arrive whole, it is tempting to just send('user joined: bob') and call it done. It works — for exactly one kind of message. The instant you have a second kind (“user left”, “new message”, “typing…”), your receiver is reduced to fragile string-sniffing: does it start with “user joined”? does it contain a colon? That is a parser you are accidentally writing by hand, badly.

The discipline that scales is simple and worth committing to memory:

  • One JSON object per WebSocket message. Never pack two logical messages into one send, and never split one logical message across several.
  • Every message has a type field. The receiver branches on type, not on string shape.
  • The payload lives in named fields, so adding data later never breaks existing parsing.

This is your application-level framing: the WebSocket frames the bytes; your type field frames the meaning.

The demo sends three different kinds of message over one connection. Each is a single JSON object with a type, and the receiver dispatches on that type — no string-sniffing anywhere. It runs in your browser on an in-page echo socket using the real WebSocket API.

JavaScript

Three sends became three onmessage calls — the WebSocket kept the boundaries perfect. And because each message announced its type, the receiver handled all three kinds with a clean if/else instead of guessing from text. Add a fourth message type tomorrow and only one new branch changes.

How does WebSocket message delivery differ from raw TCP?
Since messages already arrive whole, why still design an application protocol?
What is the recommended framing discipline in this lesson?