Serialization and Framing

Inside your program, a chat message is a tidy object: { user: "alice", text: "hi", ts: 42 } — three fields, sitting at some address in RAM, wired together by pointers your language manages for you. But a socket cannot carry an object. A socket carries bytes: a flat, one-dimensional river of numbers 0–255, with no fields, no pointers, and — this is the sting in the tail — no marks showing where one message stops and the next begins. Getting your data onto that river, and safely off it again at the far end, is two distinct jobs that beginners forever confuse.

You need both, and they are independent choices. This page builds each from the ground up, and its centrepiece is a working deframer — the exact code every networked program secretly contains — that reassembles messages no matter how cruelly the network chops the byte stream.

Serialization: text vs binary

To serialize is to answer "how do I write this object as bytes so the other side can rebuild it?" The great divide is between text formats and binary formats.

Text formats — JSON, XML, YAML. The bytes are human-readable characters. { "user": "alice", "text": "hi", "ts": 42 } is literally those characters, UTF-8 encoded. The huge win is that the format is self-describing: the field names travel with the data, so anyone — a person, a new service, a debugger — can read it without a prior agreement. The cost is size and speed: you ship the string "user" on every single message, and parsing text into numbers is comparatively slow.

Binary formats — Protocol Buffers, Avro, Thrift, MessagePack. The bytes are a compact machine encoding. Schema-based binary formats like Protocol Buffers take this furthest: both sides share a .proto schema that says "field 1 is a string called user, field 2 is a string called text, field 3 is an int called ts." On the wire you then send only field number + value — never the names. That is dramatically smaller and faster to parse, but the bytes are not self-describing: without the schema they are meaningless, and both ends must agree on it in advance.

ConcernJSON (text)Protocol Buffers (schema binary)
Size on the wireLarge — field names + text digits repeatedSmall — field numbers, packed binary
Parse speedSlower (text → values)Faster (direct binary decode)
Self-describing?Yes — readable without a schemaNo — needs the shared .proto
Human-debuggable?Yes — eyeball it in a logNo — needs a decoder
Schema evolutionAd hoc (ignore unknown keys)Designed in — reserved field numbers, add without breaking old readers

Schema evolution is the quiet reason big systems lean binary. Protobuf gives every field a number, and old code simply skips field numbers it doesn't recognise. So you can add a field 4 next year and last year's servers keep working — forward and backward compatible by design. Hand-rolled binary formats that hard-code byte offsets have no such grace: insert a field in the middle and every old reader shatters.

Serialize the 32-bit integer 1 and you must write four bytes — but in which order? A big-endian machine writes the most-significant byte first (00 00 00 01); a little-endian machine (x86, most ARM) writes the least-significant first (01 00 00 00). If sender and receiver disagree, 1 becomes 16777216. The internet settled this by decree: multi-byte integers in protocol headers go big-endian, and we call that convention network byte order. It is why C programmers reflexively wrap integers in htonl() ("host to network long") before sending. The demo below shows the two orderings turning the same number into different bytes.

// Encode a 32-bit unsigned int into 4 bytes, big-endian (network order). function toBigEndian(n) { return [ (n >>> 24) & 0xff, (n >>> 16) & 0xff, (n >>> 8) & 0xff, n & 0xff ]; } function toLittleEndian(n) { return toBigEndian(n).reverse(); } const hex = (bytes) => bytes.map((b) => b.toString(16).padStart(2, "0")).join(" "); for (const n of [1, 256, 0x01020304]) { console.log(`n = ${n}`); console.log(` big-endian (network order): ${hex(toBigEndian(n))}`); console.log(` little-endian : ${hex(toLittleEndian(n))}`); } console.log("\nIf the two ends disagree on order, the same 4 bytes decode to a different number.");

Framing: where does each message end?

Suppose you have serialized three messages and written them to a TCP socket. The receiver gets a stream of bytes with the boundaries erased — it must re-discover them. There are exactly two classic strategies, and essentially every real protocol picks one.

The centrepiece: a deframer that survives arbitrary chunking

Here is the crux. TCP delivers bytes in whatever chunk sizes it likes — a data event might hand you half a length header, or three messages glued together, or one byte at a time. A correct deframer therefore cannot assume anything about chunk boundaries. It must buffer incoming bytes and repeatedly ask: "do I now hold a complete frame? If so, emit it and keep the leftover; if not, wait for more." The code below implements a length-prefixed framer and a stateful deframer, then feeds it a stream sliced at the worst possible places — including mid-header.

const HEADER = 4; // 4-byte big-endian length prefix // --- FRAMER: object/string -> [4-byte length][payload] bytes --- function frame(payload) { const body = Array.from(payload).map((c) => c.charCodeAt(0)); const n = body.length; const header = [ (n >>> 24) & 0xff, (n >>> 16) & 0xff, (n >>> 8) & 0xff, n & 0xff ]; return header.concat(body); } // --- DEFRAMER: a stateful buffer that emits whole messages as they complete --- function makeDeframer(onMessage) { let buf = []; // bytes seen so far, not yet consumed return function feed(chunk) { // called with each arbitrary arriving chunk buf = buf.concat(chunk); while (true) { if (buf.length < HEADER) return; // not even a full header yet — wait const n = (buf[0] << 24) | (buf[1] << 16) | (buf[2] << 8) | buf[3]; if (buf.length < HEADER + n) return; // header known, payload incomplete — wait const payload = buf.slice(HEADER, HEADER + n) .map((b) => String.fromCharCode(b)).join(""); onMessage(payload); // a WHOLE message is ready buf = buf.slice(HEADER + n); // drop it; keep the remainder } }; } // Frame three messages and concatenate into one wire stream. const wire = [].concat(frame("LOGIN alice"), frame("MSG hi there"), frame("LOGOUT")); // Now deliver that stream in DELIBERATELY cruel chunks: mid-header, glued, 1 byte. const chunks = [ wire.slice(0, 2), wire.slice(2, 9), wire.slice(9, 10), wire.slice(10) ]; console.log(`${wire.length} bytes arriving as ${chunks.length} chunks of sizes ` + chunks.map((c) => c.length).join(", ") + ":\n"); const feed = makeDeframer((msg) => console.log(` emitted whole message: "${msg}"`)); chunks.forEach((c, i) => { console.log(`feed chunk ${i} (${c.length} bytes)...`); feed(c); }); console.log("\nEvery message recovered intact — even though chunk 0 held only HALF the first header.");

Trace the state: chunk 0 is 2 bytes — not even a full 4-byte header — so feed buffers and returns, emitting nothing. Only once enough bytes have accumulated does the while loop fire, and it keeps firing as long as complete frames remain, leaving any partial tail in buf for next time. That buffer-and-loop is the heart of every real network reader.

Putting it together

Serialization and framing compose: you serialize the object to a payload (JSON, protobuf, whatever), then frame the payload for the stream (length-prefix it, or delimit it). The receiver reverses both: deframe to recover the payload bytes, then deserialize to rebuild the object. Many real protocols even do both at once — HTTP frames its headers with \r\n delimiters and its body with a Content-Length length prefix, all in one message, which is exactly the machinery you'll assemble on the HTTP-server page.

Revisited, because it is the bug. A data event (one read()) has no relationship to a message. It may hold a fraction of a message, exactly one, or several — including a partial header, as the demo's 2-byte first chunk showed. Any reader that does parse(chunk) directly, without buffering across reads, is broken; it merely appears to work on localhost where writes often arrive one-to-one. Always buffer and re-check "do I have a complete frame yet?"

The delimiter trap. If you frame with a delimiter — say \n — and your payload can itself contain that byte, a naïve reader will split one message into two at the embedded delimiter. You must either escape the delimiter in the payload (and un-escape on read), forbid it entirely, or switch to length prefixing, which is delimiter-agnostic and the safe default for arbitrary binary data. "It can't contain a newline" is a promise your payload will one day break.