“TCP is reliable” is one of those phrases that’s true and misleading at the same time. It’s reliable in a narrow, precise sense: the bytes you write come out the other end in the same order, with nothing missing and nothing duplicated, or the connection fails with an error. What TCP doesn’t give you is messages. Interviewers like this topic because the gap between “reliable” and “delivers my messages” is where a lot of real bugs live, and because the fix (framing) is something you can write on a whiteboard.
Everything below was run with Node 22 on localhost.
What TCP actually promises
TCP numbers every byte it sends. The receiver acknowledges how far it has received without a gap, and the sender retransmits anything that isn’t acknowledged in time. Out-of-order segments are held back and put in sequence. A checksum catches most corruption. The result is an ordered, gap-free stream of bytes.
Notice what’s missing from that list:
- Message boundaries. TCP has no idea your application wrote “three messages”. It sees a stream of bytes.
- Delivery to the application. An acknowledgement means the other machine’s kernel has the bytes in a buffer. The process might crash before it reads them.
- Timing. Retransmission can stall the stream for seconds while everything behind a lost segment waits.
- Protection from tampering. The 16-bit checksum is there to catch accidents, not attackers. That’s TLS’s job.
Writes aren’t messages
Here’s the experiment that makes the first point concrete. A client writes three small JSON objects with three separate calls, and the server logs each data event it receives:
client.write('{"id":1}');
client.write('{"id":2}');
client.end('{"id":3}');read: "{\"id\":1}{\"id\":2}{\"id\":3}"Three writes, one read. The writes happened faster than the data was handed to the reader, so the receiving side got everything in a single chunk. A server that calls JSON.parse on each chunk breaks immediately:
SyntaxError: Unexpected non-whitespace character after JSON at position 8 (line 1 column 9)The opposite happens too. Swapping the client to a single 1,000,000-byte write, and logging the size of each read, gave this on two consecutive runs:
16 reads: 65536, 65536, 65536, 65536, 65536, 65536, 65536, 65536, 65536, 65536, 65536, 31416, 65536, 65536, 65536, 51080
17 reads: 65536, 65536, 65536, 65536, 65536, 65536, 65536, 65536, 65536, 65536, 65536, 15240, 65536, 65536, 65536, 65536, 1720One write, sixteen or seventeen reads, and the split points moved between runs. Buffer sizes, segment sizes, Nagle’s algorithm (which holds small writes back briefly to combine them) and plain timing all affect where the cuts land. On localhost the pieces are big; across a real network with a 1,460-byte segment size and packet loss, they get smaller and less predictable.
This is why the bug often hides during development. Locally, small messages usually arrive one per read because nothing else is happening. Under production load, with real latency, they start merging and splitting.
Three ways to say where a message ends
Every protocol built on TCP solves this the same few ways:
- A delimiter. End each message with a byte sequence that can’t appear inside it. Newline-delimited JSON, SMTP and Redis’s protocol all use line endings. It’s easy to debug with
nc, but the delimiter has to be escaped or forbidden inside messages. - A length prefix. Send the size first, then exactly that many bytes. Binary protocols (Kafka, PostgreSQL’s wire protocol, gRPC on top of HTTP/2 frames) do this. It handles arbitrary binary data with no escaping.
- One message per connection. Close the connection to mark the end. HTTP/1.0 worked like that. It’s simple and wasteful, since you pay a handshake per message.
HTTP/1.1 mixes these: headers end at a blank line (a delimiter), and the body is either Content-Length bytes long (a length prefix) or sent in chunks that each carry their own length.
A length-prefixed decoder that survives any split
Here’s a complete encoder and decoder using a 4-byte big-endian length:
const MAX_FRAME = 1024 * 1024; // refuse frames over 1 MB
export function encode(obj) {
const body = Buffer.from(JSON.stringify(obj));
const header = Buffer.alloc(4);
header.writeUInt32BE(body.length);
return Buffer.concat([header, body]);
}
export function createDecoder(onMessage) {
let buffered = Buffer.alloc(0);
return (chunk) => {
buffered = Buffer.concat([buffered, chunk]);
while (buffered.length >= 4) {
const length = buffered.readUInt32BE(0);
if (length > MAX_FRAME) throw new Error(`frame of ${length} bytes exceeds limit`);
if (buffered.length < 4 + length) return; // wait for the rest
onMessage(JSON.parse(buffered.subarray(4, 4 + length)));
buffered = buffered.subarray(4 + length); // keep any extra bytes
}
};
}To test it against the worst case, the client sent three encoded messages one byte at a time with a 1 ms pause between writes, and the server fed every data chunk into the decoder:
message { id: 1 }
message { id: 2, text: 'hello' }
message { id: 3 }
50 readsThe 51 bytes arrived in 50 reads on that run (51 on another), and the decoder produced exactly three messages either way. Each line of the decoder handles a case interviewers will probe:
- Buffering. A chunk might end halfway through the 4-byte header or halfway through the body. Without the buffer, those bytes are lost.
- The
whileloop. One chunk can hold several complete frames, as the first experiment showed. Anifwould process the first and silently strand the rest until the next read. - Keeping the remainder. After a frame, whatever is left belongs to the next one.
- The size cap. Without it, a client can send a header claiming four gigabytes and your server will try to buffer it. Reading a length from the network is reading untrusted input.
Note
Concatenating buffers on every chunk is fine for small messages. For large frames, collect chunks in an array and join them once the full length has arrived, or use a library’s framing stream.
Where “reliable” stops
The other half of the interview answer is what happens when things go wrong. If the connection resets, any bytes in flight or sitting in buffers are gone, and the sender can’t tell which of its writes the receiving application actually processed. The kernel may have acknowledged them all.
So applications that care add their own layer: each message gets an ID, the receiver confirms what it has processed, and the sender resends unconfirmed messages after reconnecting. That makes duplicates possible, so the receiver has to handle a repeated ID safely. Message brokers and payment APIs are built around exactly this.
Interview practice
A chat server written with raw TCP sockets works on a developer’s laptop. In production, users report that some messages show up merged with the next one and others arrive cut off. The code calls JSON.parse on each data event. What’s wrong, and how would you fix it?
Answer
The code assumes each data event is one message, but TCP is a byte stream and keeps no message boundaries. On a quiet laptop, messages happen to arrive one per read, so the bug never shows. In production, latency and load cause several messages to arrive in one read (which looks merged) and single messages to be split across reads (which looks cut off).
The fix is framing. I’d either end each message with a newline and split on it, keeping any partial line for the next read, or put a length prefix on each message and use a decoder like the one above. The decoder needs a buffer for partial frames, a loop for several frames in one read, and a maximum frame size so a bad header can’t exhaust memory. I’d test it by deliberately writing one byte at a time, since that’s the split nobody thinks of. If losing a message on reconnect matters, I’d also add message IDs and acknowledgements at the application level, because TCP’s own acknowledgements stop at the kernel.