Node.js TCP Message Framing

Use Node.js TCP sockets with length-prefixed messages, handle split and combined chunks, limit frame sizes, and verify a complete local client.

Lesson 31 of 43Intermediate10 min read

TCP reliably delivers an ordered stream of bytes between connected peers. It does not preserve the boundaries between calls to socket.write. A sender can write two messages and a receiver can observe one combined chunk, several split chunks, or another partition of the same ordered bytes. Applications therefore need a protocol that identifies message boundaries. This Node 24 reference implements a small length-prefixed text protocol over a loopback connection. Its explicit parser makes the protocol rule visible instead of relying on the operating system to happen to deliver convenient chunks during a test.

Define a framing contract first

Common framing strategies include newline-delimited text, fixed-size records, and a length prefix followed by a payload. Each strategy has a format contract. Newline framing requires a rule for newlines inside a value. Fixed-size framing needs agreed widths and padding. Length prefixes require agreed byte order, width, and maximum size. The fixture uses a two-byte unsigned big-endian payload length, followed by UTF-8 bytes, with an application maximum of 1024 bytes.

The length describes encoded bytes, not JavaScript string length. An accented character can occupy several bytes, so measuring the string directly creates a malformed frame. The parser waits until it has both header bytes, reads the declared length, checks the maximum, and waits until the complete payload is available. It can then decode that payload and continue examining any remaining bytes. This loop is essential because a single chunk can contain more than one complete frame. TCP reliability does not perform any of these application-format decisions.

Retain incomplete input without losing later messages

A parser needs state between data events. Its pending buffer contains bytes received but not yet consumed. Appending a chunk does not imply a message is ready. If the header is incomplete, wait. If the header is complete but the payload is incomplete, keep the entire partial frame for the next call. Once a frame is consumed, retain only the following bytes. The fixture's parser does this with Buffer.concat and subarray, making the state transition easy to inspect.

Repeatedly concatenating very large inputs can be expensive because it copies retained bytes. A production parser may use a buffer queue or an incremental allocation strategy while preserving the same framing rules. Bound retained input as well as individual message size, and reject a peer that sends invalid lengths or stalls indefinitely mid-frame. An end event with a partial header or payload should be treated as a truncated message. The small demonstration accepts controlled peers and focuses on valid complete frames; add an explicit finish check when turning it into a reusable protocol implementation.

Manage socket events and writes

net.createServer supplies a socket for each accepted connection. net.createConnection creates a client socket. Register data, error, and close behavior and understand that connect, end, and close describe different stages. connect means the connection has been established. end means the remote writable side has ended its stream. close means the socket has closed. A protocol may use a half-close, where one side finishes writing while it still reads a response.

socket.write returns a backpressure signal. A false return does not mean the bytes were rejected; it means the writable buffer reached its threshold and the producer should pause until drain before continuing. An unbounded producer can exhaust memory despite TCP flow control if it ignores that signal. socket.end sends any final data and finishes the writable side. socket.destroy terminates the connection abruptly and is appropriate after an unrecoverable protocol error. The fixture ends the server's response after two frames and waits for the client to consume the stream before closing its listener.

Distinguish TCP from higher-level guarantees

An ordered connection is not authentication, encryption, or durable message processing. For confidential traffic, use a suitable TLS protocol rather than sending secrets through an unencrypted raw socket. A message sent before a connection failure may or may not have been processed by the remote application. Retrying a payment-like operation therefore needs identifiers and idempotency, not just a fresh TCP connection. Acknowledgements and request-response matching belong to the application protocol.

TCP also does not protect a parser from unreasonable input. Set resource limits, timeouts, and a response to invalid frames. Node socket timeout events require your code to decide what happens next; merely configuring a timeout does not define a complete cleanup policy. Do not expose the educational server to the public internet. Its fixed loopback binding and ephemeral port keep the test local. Production protocols must define versioning, malformed UTF-8 behavior, authorization, connection concurrency, and shutdown semantics in addition to the length field shown here.

Node.js documentation and version notes

Node.js TCP Message Framing Examples

Each example shows the code, the output it produces and what each part does.

Example 1

Framing UTF-8 messages over one TCP connection

The complete fixture starts a local server, frames two UTF-8 messages, and sends them through one TCP connection. The client parser prints logical messages after reconstructing their boundaries. The result stays the same whether the runtime combines or splits the network data. The server listener closes in finally, and the client rejects its promise on a socket error. The parser's maximum checks happen before waiting for a declared large payload. Its decoded text is appropriate because this protocol explicitly declares UTF-8; a protocol carrying arbitrary binary values should pass the payload Buffer onward instead.

JavaScript
import net from 'node:net';
import { once } from 'node:events';
function createParser(onMessage) {
  let pending = Buffer.alloc(0);
  return chunk => {
    pending = Buffer.concat([pending, chunk]);
    while (pending.length >= 2) {
      const size = pending.readUInt16BE(0);
      if (size > 1024) throw new Error('Frame too large');
      if (pending.length < 2 + size) break;
      onMessage(pending.subarray(2, 2 + size).toString('utf8'));
      pending = pending.subarray(2 + size);
    }
  };
}
function frame(text) {
  const payload = Buffer.from(text);
  if (payload.length > 1024) throw new Error('Frame too large');
  const packet = Buffer.alloc(2 + payload.length);
  packet.writeUInt16BE(payload.length);
  payload.copy(packet, 2);
  return packet;
}
const server = net.createServer(socket => {
  socket.on('error', () => {});
  socket.end(Buffer.concat([frame('hello'), frame('café')]));
});
server.listen(0, '127.0.0.1');
await once(server, 'listening');
try {
  await new Promise((resolve, reject) => {
    const client = net.createConnection({ host: '127.0.0.1', port: server.address().port });
    const parse = createParser(message => console.log(message));
    client.on('data', chunk => {
      try { parse(chunk); }
      catch (error) { client.destroy(error); }
    });
    client.on('error', reject);
    client.on('end', resolve);
  });
} finally { await new Promise(resolve => server.close(resolve)); }
Output
hello
café

Node.js TCP Message Framing in Real Projects

Test a frame parser with awkward chunk splits

Test the parser against deliberately awkward partitions without opening a socket. Encode two messages together, then feed the first header byte separately, the next few bytes separately, and all remaining bytes at the end. This removes the accidental dependency on network chunk timing. Run a second parser with the entire combined buffer to prove that its loop consumes both messages. Finally feed a header declaring more than the configured maximum. A parser that equates one chunk with one message fails at least one of these cases. The copied framing functions make the practice file independent of the main example.

JavaScript
function createParser(onMessage) {
  let pending = Buffer.alloc(0);
  return chunk => {
    pending = Buffer.concat([pending, chunk]);
    while (pending.length >= 2) {
      const size = pending.readUInt16BE(0);
      if (size > 1024) throw new Error('Frame too large');
      if (pending.length < 2 + size) break;
      onMessage(pending.subarray(2, 2 + size).toString('utf8'));
      pending = pending.subarray(2 + size);
    }
  };
}
function frame(text) {
  const payload = Buffer.from(text);
  if (payload.length > 1024) throw new Error('Frame too large');
  const packet = Buffer.alloc(2 + payload.length);
  packet.writeUInt16BE(payload.length);
  payload.copy(packet, 2);
  return packet;
}
const bytes = Buffer.concat([frame('A'), frame('é')]);
const split = [];
const feed = createParser(message => split.push(message));
feed(bytes.subarray(0, 1));
feed(bytes.subarray(1, 4));
feed(bytes.subarray(4));
console.log(JSON.stringify(split));
const combined = [];
createParser(message => combined.push(message))(bytes);
console.log(JSON.stringify(combined));
try { createParser(() => {})(Buffer.from([0x04, 0x01])); }
catch (error) { console.log(error.message); }
Output
["A","é"]
["A","é"]
Frame too large

Node.js TCP Message Framing FAQ

Does each socket.write become one data event?

No. TCP preserves byte order, not application write boundaries. A receiver can observe split or combined chunks. The parser must identify boundaries from an agreed framing contract and retain incomplete bytes between events. Testing only small local writes can create a misleading impression that one write corresponds to one event. Deliberately split headers and combine frames in unit fixtures to verify the actual protocol behavior.

Why check the length before waiting for the payload?

A malicious or faulty peer can declare an enormous message and leave the connection waiting or accumulating memory. Validate the declared size immediately once the header is complete. Then enforce connection and buffering limits as well. A two-byte header inherently limits this particular format to 65535 bytes, but the application's 1024-byte policy is intentionally much smaller. Format capacity is not the same as an acceptable resource limit.

What does a false socket.write result mean?

The write has entered the writable system, but the buffer threshold has been reached. Pause further production and resume after drain. It does not mean you should resend the same bytes, which could duplicate data. Backpressure coordinates the producer with available buffering capacity. It also does not prove that the remote application processed the message; an application acknowledgement is a separate protocol feature.

Can I decode every chunk as UTF-8 before parsing?

That loses the byte-based framing contract and can corrupt a character split between chunks. First reconstruct the complete byte payload according to its header, then decode that payload using the declared encoding. Keep header offsets and lengths in bytes. If the protocol accepts binary data, do not decode at all. For newline text protocols, use a stateful decoder and an explicit delimiter parser instead.

Node.js TCP Message Framing Exercises

Answer each one and check it instantly. If you get stuck, open the hint before the answer.

Multiple choiceeasy

When the practice feeds two frames as one combined chunk, how many messages are emitted?

Node.js TCP Message Framing Quiz

2 questions to check what you learned.

  1. What guarantee does TCP provide to this parser?

    • Ordered bytes within the connection
    • One data event for each sender write
    • Automatic JSON validation for every payload
  2. Which length belongs in the fixture header for café?

    • Its UTF-8 byte length
    • Its JavaScript string length regardless of encoding
    • The number of data events received