TCPTCPbyte streamreliabilitysequence numbersacknowledgment

TCP: A Reliable Ordered Byte Stream over an Unreliable Network

IP loses, duplicates, reorders and delays packets and says nothing about it; TCP turns that into a connection over which bytes arrive exactly once, in order, at a rate the receiver and the network can absorb — by numbering every byte, acknowledging what arrived, retransmitting what did not, and windowing what is in flight — and it hands the application a stream, not messages.

ConceptualUnix-styleLinux
Journey: What happens when I send a packet?Interview question
Progress

The problem

IP will lose, duplicate, reorder and delay your packets and tell you nothing. Your application wants to write() 10 MB and have exactly those bytes arrive at the other end, in order, once, without overrunning the receiver or the network. Who bridges that gap, and what does the bridge cost?

Progressive depth

The same mechanism at different altitudes — start where you are.

A reliable ordered pipe

You connect, you write bytes, they come out the other end in order and complete, or the connection fails. Everything TCP does is in service of that one sentence, and everything it costs is the price of it.

The contract

TCP promises a connection between two endpoints carrying a reliable, ordered, full-duplex byte stream. Reliable: every byte you write is delivered or the connection fails — there is no third outcome. Ordered: bytes are read in the order they were written. Full duplex: both directions are independent streams. Byte stream: the unit is the byte; the boundaries of your write() calls do not exist on the other side.

It makes this promise over IP, which promises nothing (IP: Best-Effort Delivery Between Machines). The gap is closed by a small set of mechanisms that each answer one failure: sequence numbers so the receiver can order and de-duplicate; acknowledgments so the sender learns what arrived; retransmission on timeout or duplicate ACKs for what did not; flow control so the sender does not overrun the receiver’s buffer; congestion control so it does not overrun the network; and a checksum so corruption is treated as loss. A handshake at the start agrees the numbering, and a close sequence at the end ensures both sides know it is over.

The cost is latency and state. Setting up a connection is a round trip before the first byte (The Three-Way Handshake); a lost segment stalls the stream for at least a round trip and often several (Packet Loss: Duplicate ACKs, Fast Retransmit and the RTO); a new connection starts slowly and ramps up (Congestion Control: Protecting the Network); and both endpoints hold buffers and timers for every open connection (The Connection Lifecycle: Close, Reset, TIME_WAIT, CLOSE_WAIT). TCP is the correct default for anything that must be complete and correct; UDP: Datagrams and the Contract You Choose is the alternative when it is not.

Where TCP sits
  1. Application: write(fd, buf, 10 MB)copies into the socket send buffer; returns before anything is sent
  2. TCP: segments of ≤ MSS bytessequence numbers, checksums, windows; retransmit timers
  3. IP: packetsbest effort; may lose, reorder, duplicate
  4. Receiver TCP: reassembly bufferorders, de-duplicates, ACKs, advertises window
  5. Application: read(fd, buf, n)gets the next in-order bytes, however many are ready

The mechanisms in one screen

Each mechanism is its own lesson; here is the map. The receiver acknowledges the highest in-order byte it has (cumulative ACK) and, with SACK, the out-of-order ranges too. The sender keeps everything unacknowledged in its buffer and retransmits when a timer fires or when three duplicate ACKs say the receiver has a hole. It never has more than min(rwnd, cwnd) bytes in flight: rwnd, the receiver’s advertised free buffer, protects the receiver; cwnd, the sender’s estimate of what the network will carry, protects the network.

Bytes, not messages: the framing bug

The most common TCP bug in application code has nothing to do with the network. A client does send(json); the server does recv() and parses what it gets. It works in every test because on a LAN a small write usually arrives as one segment and is read in one call. In production the server receives half a JSON document, or one and a half, and the parser throws. TCP never promised that one send is one recv: segments are split at the MSS, merged by Nagle’s algorithm or by the receiver’s buffer, and delivered as "whatever bytes are in order right now".

Every protocol over TCP therefore defines its own framing: a length prefix (gRPC, most binary protocols), a delimiter (HTTP/1.1 headers end at a blank line, then Content-Length or chunked encoding frames the body; Redis uses \r\n), or a fixed-size header that says how much follows. The receiving code must buffer until a complete frame is present and must handle a read that returns several frames at once. If you write a TCP protocol and skip this step, it will fail in exactly the way that cannot be reproduced locally.

The bug, and the fix: accumulate until a full frame is present
1// BUG: assumes one 'data' event == one message. Works on localhost, fails in production.
2socket.on('data', (chunk) => handle(JSON.parse(chunk.toString())))
3
4// FIX: length-prefixed framing. 4-byte big-endian length, then that many bytes.
5let buf = Buffer.alloc(0)
6socket.on('data', (chunk) => {
7 buf = Buffer.concat([buf, chunk])
8 while (buf.length >= 4) {
9 const len = buf.readUInt32BE(0)
10 if (buf.length < 4 + len) break // partial frame: wait for more bytes
11 handle(JSON.parse(buf.subarray(4, 4 + len).toString()))
12 buf = buf.subarray(4 + len) // there may be another frame already here
13 }
14})

The 4-tuple and the header at a glance

A connection is identified by the 4-tuple (source IP, source port, destination IP, destination port) — plus the protocol, which is why some say 5-tuple. Every incoming segment is matched to a socket by that tuple, which is how one server on port 443 serves fifty thousand clients: each has a different source address or port. It is also why a client that opens many connections to one server needs many source ports, the subject of The Connection Lifecycle: Close, Reset, TIME_WAIT, CLOSE_WAIT and Ports: Addressing a Process, Not a Machine.

The header is 20 bytes minimum, up to 60 with options. Ports (2×16 bits), a 32-bit sequence number, a 32-bit acknowledgment number, the header length, flags (SYN, ACK, FIN, RST, PSH, URG, plus ECE/CWR for ECN), a 16-bit window, a checksum, an urgent pointer nobody uses, and options: MSS, window scale, SACK-permitted / SACK blocks, and timestamps. With a 20-byte IPv4 header and a 1500-byte MTU, the MSS is 1460 bytes of payload per segment; timestamps and SACK options reduce it to 1448 in most real segments.

TCP header layout (RFC 9293)
 0                   1                   2                   3
 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-------------------------------+-------------------------------+
|          Source Port          |       Destination Port        |
+-------------------------------+-------------------------------+
|                        Sequence Number                        |
+---------------------------------------------------------------+
|                    Acknowledgment Number                      |
+-------+-----+-+-+-+-+-+-+-+-+-+-------------------------------+
| Data  | Rsv |C|E|U|A|P|R|S|F|            Window             |
| Offset|     |W|C|R|C|S|S|Y|I|                               |
|       |     |R|E|G|K|H|T|N|N|                               |
+-------+-----+-+-+-+-+-+-+-+-+-+-------------------------------+
|           Checksum            |         Urgent Pointer        |
+-------------------------------+-------------------------------+
|  Options (MSS, window scale, SACK, timestamps) ...  padding   |
+---------------------------------------------------------------+
|                             data                              |

Where TCP lives, and how you touch it

Unix-style

On every mainstream OS, TCP is in the kernel. Your program sees a socket — a file descriptor with a send buffer and a receive buffer behind it (The Socket: A Descriptor With Two Kernel Buffers Behind It, The Buffer Chain). write() copies into the send buffer and returns; the kernel segments, sends, retransmits and paces on its own timers, driven by interrupts from the NIC and by the ACKs that come back. read() returns whatever in-order bytes have accumulated in the receive buffer, or blocks until some do. Nothing about segments, ACKs or windows is visible from the API, which is both the point and the reason the framing bug exists.

Six lines are enough to use it. What they hide — DNS, the handshake, slow start, retransmission, the close — is the rest of this module. User-space TCP stacks exist (in DPDK-based systems, in some databases and load balancers, and QUIC is effectively one over UDP), but "TCP" in an ordinary program means the kernel’s.

A complete TCP client
1import socket
2s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
3s.connect(("203.0.113.10", 80)) # DNS already done; handshake happens here (1 RTT)
4s.sendall(b"GET / HTTP/1.1\r\nHost: engineer-atlas.dev\r\n\r\n") # copies into the send buffer
5data = s.recv(4096) # some bytes; not necessarily the whole response
6s.close() # FIN; TIME_WAIT is now ours

Key points

  • TCP turns best-effort packets into a reliable, ordered, full-duplex byte stream with sequence numbers, ACKs, retransmission, windows and a checksum.
  • The unit is the byte: write() boundaries vanish, and every protocol on TCP must frame its own messages — forgetting this is the most common TCP bug.
  • In flight ≤ min(rwnd, cwnd): flow control protects the receiver, congestion control protects the network.
  • A connection is a 4-tuple; a server on one port serves many clients because their source addresses and ports differ.
  • The header is 20–60 bytes; MSS is 1460 on Ethernet/IPv4 and usually 1448 with options.
  • The costs are a handshake RTT, stalls on loss, slow start, and per-connection state — which is why connections are reused.

Why does this exist?

Mechanisms are answers to constraints. Open each question before reading the answer.

Why not make IP reliable instead?

Because reliability needs per-connection state — sequence numbers, buffers, timers — at the endpoints, and routers must stay stateless to forward at line rate. Putting reliability at the ends (the end-to-end argument) also lets applications that do not want it, like real-time media, skip it.

Why a byte stream and not messages?

Because the network’s natural unit (the segment, bounded by MTU) does not match the application’s, and the stream lets TCP re-segment freely for retransmission, coalescing and offload. The price is that applications frame their own messages; the benefit is that TCP never has to know what a message is.

Why does TCP need a handshake when UDP does not?

Sequence numbers and options must be agreed before either side can interpret a data segment, and each side needs proof the other is really there and listening; without it, an old duplicate SYN could create a connection nobody wanted.

How it fails

What the failure looks like from inside real software.

  • Parsing each recv() as a message: works locally, throws on partial or coalesced reads in production; the classic framing bug.
  • Calling send() once and assuming all bytes went: short writes on a full send buffer drop the tail of the message silently.
  • One connection per request at high rate: handshake latency on every request, TIME_WAIT accumulation, ephemeral-port exhaustion on the client.
  • No application-level timeout on a read: a peer that vanished (power loss, firewall state expired) leaves recv() blocked for 15+ minutes until the kernel gives up.
  • Assuming a successful write() means the peer received the data: it means the kernel accepted it; the peer may be dead and the data still in the send buffer.
  • Treating ECONNRESET as a bug in your code: it usually means the peer or a middlebox closed the connection abruptly — a load balancer idle timeout, a crashed process, a NAT that forgot the flow.

Follow it through every layer

This lesson is one node of a longer journey. Zoom out, then zoom back in.

Don't delegate understanding
The manifesto →