Under the Hood
Networking

TCP: the connection under everything

How TCP turns a network that loses, reorders, and duplicates packets into the ordered reliable byte stream every fetch depends on — the three-way handshake byte by byte, what a connection physically is, flow vs congestion control and why a cold connection starts slow, retransmission and head-of-line blocking, and MSS/MTU.

TCP: the connection under everything

In the fetch lesson we said "TCP gives you an ordered, reliable byte stream" and moved on. That one sentence is doing an enormous amount of work, and this lesson is about what's underneath it. Because here's the uncomfortable truth: the network your packets actually travel on — IP, the Internet Protocol — guarantees nothing. It will lose packets, deliver them out of order, duplicate them, and corrupt them, and it will never tell you it did. IP's only job is "best effort, good luck."

Yet await res.json() gives you clean bytes in the right order every time. Every network promise you've awaited sat on top of a system that quietly detected the loss, re-requested the missing pieces, discarded duplicates, and reassembled everything in order before your code saw a byte. That system is TCP, and it runs in your operating system's kernel, not your app. This lesson is how it pulls that off — and why the machinery has a cost you can feel.

Reliability built on top of unreliability

IP moves packets between IP addresses. A packet is a self-contained chunk with a source address, a destination address, and a payload. Each is routed independently — two packets sent back to back can take different paths, arrive out of order, or not arrive at all. There is no connection at the IP layer; just individual packets hurled toward an address.

TCP's whole job is to build a reliable, ordered conversation out of that. It does it with three ideas, and everything else is detail:

  • Sequence numbers — every byte gets a number, so the receiver can put bytes back in order and spot gaps.
  • Acknowledgements (ACKs) — the receiver tells the sender "I have everything up through byte N." Anything unacknowledged is assumed lost and re-sent.
  • A connection — both sides agree on starting sequence numbers up front and keep per-connection state, so those numbers mean something.

None of this exists on the wire as a physical thing. A TCP connection is bookkeeping — a shared agreement between two kernels about what's been sent and received. Hold onto that; it matters more than it sounds.

The three-way handshake, byte by byte

Before any data flows, the two sides synchronize their sequence numbers. This is the three-way handshake: SYNSYN-ACKACK. "SYN" is short for synchronize — it carries an ISN (Initial Sequence Number), the random starting point each side will count its bytes from. (Random, not zero, for security — a predictable ISN lets an attacker forge packets into your stream.)

  Client                                              Server
    │                                                    │
    │  SYN   seq=1000                                    │   "let's talk. I'll number
    ├───────────────────────────────────────────────────►   my bytes starting at 1000"
    │                                                    │
    │                     SYN-ACK  seq=5000, ack=1001    │   "ok. I start at 5000, and
    │◄───────────────────────────────────────────────────   I've got your 1000 — send 1001 next"
    │                                                    │
    │  ACK   seq=1001, ack=5001                          │   "got your 5000. we're synced."
    ├───────────────────────────────────────────────────►
    │                                                    │
    │  ── connection ESTABLISHED, first data can flow ── │

Read the numbers. The client picks ISN 1000 and sends SYN. The server picks its own ISN 5000, acknowledges the client's with ack=1001 (meaning "I have everything through 1000, send me 1001 next" — ACKs name the next expected byte), and sends its own SYN. The client ACKs that. Three messages, and now both sides know both starting numbers.

The thing to burn into your memory: the handshake is one full round trip before a single byte of your request goes out. The client can actually piggyback data on that third ACK, but in practice your HTTP request waits for the handshake to finish. If the server is 70ms away, that's ~70ms of pure setup where nothing useful moves. This is the cost the fetch lesson kept pointing at, and it's why a warm, reused connection is so much cheaper than a cold one — the handshake already happened.

What "a connection" physically is

Ask "where is the connection?" and the honest answer is: nowhere, and in two places at once. There is no wire held open, no circuit reserved. A TCP connection is identified entirely by a 4-tuple:

(source IP, source port, destination IP, destination port)
   192.0.2.5 : 51514  →  203.0.113.10 : 443

That's it. Any two packets sharing all four values belong to the same connection; the kernel on each end keeps a block of state for that tuple — the sequence numbers, what's been ACKed, the window sizes, buffers of data waiting to be sent or reassembled. "Establishing a connection" means creating that state on both ends. "Closing" it means tearing the state down (a FIN/ACK exchange, plus a TIME_WAIT period where the tuple lingers to catch stray late packets).

Two consequences fall out of this. First, the source port is what lets your machine hold many connections to the same server at once — same three values, different source port, different tuple, different conversation. Second, because it's just kernel state and not a physical thing, a connection can be perfectly "alive" in your kernel while the other end has vanished — the state persists until something (data, a timeout, a keepalive) reveals the truth.

Flow control vs congestion control

Once you're connected, how fast can you send? Two separate brakes apply, and people constantly conflate them.

Flow control protects the receiver. Every ACK carries a receive window (rwnd) — "I have this many bytes of buffer free right now." The sender may not have more than that in flight unacknowledged. If your app reads slowly, the buffer fills, the advertised window shrinks toward zero, and the sender is told to wait. This is purely about not overwhelming the other end's memory.

Congestion control protects the network in between — the shared routers and links that no one owns. TCP has no direct way to know how much capacity the path has, so it probes. It keeps a second, hidden limit — the congestion window (cwnd) — and the amount it can send is the minimum of cwnd and the receiver's rwnd.

Here's the part that explains why cold connections start slow. A new connection doesn't know the path's capacity, so it starts timid and ramps up:

  • Slow start. cwnd begins tiny (typically 10 packets — ~14KB) and doubles every round trip as long as ACKs keep coming. That's exponential growth, but it starts from almost nothing. A fresh connection literally cannot send fast, no matter how fat your pipe is — it hasn't earned the trust yet.
  • AIMD after that. Once it crosses a threshold or sees trouble, TCP switches to Additive Increase, Multiplicative Decrease: grow cwnd by roughly one packet per round trip (cautious probing upward), but on a loss, halve it instantly. Slow to climb, quick to back off.
  • Loss is the signal. TCP treats a dropped packet as "the network is congested" and backs off. It has no other congestion signal — a lost packet is the message.

Put slow start and round trips together and you get the punchline: a cold connection is slow because it takes several round trips just to open the throttle. A big response over a brand-new connection can't use your bandwidth on the first few round trips — cwnd hasn't grown enough yet. This is precisely why keep-alive (reusing a warm connection whose window has already grown) and CDNs (putting the server physically close so each round trip is 5ms, not 70ms, so the window ramps in wall-clock-milliseconds) are such large wins. They're not shaving CPU; they're removing round trips from the ramp.

Retransmission and head-of-line blocking

When a packet is lost, the receiver keeps ACKing the last in-order byte it got — it can't ACK past the gap. The sender sees duplicate ACKs (or a timeout) and re-sends the missing segment. Correctness: preserved. But look at the cost to everything behind the gap.

TCP guarantees in-order delivery. So if segment 3 of 3,4,5,6 is lost, segments 4, 5, and 6 may have already arrived and be sitting in the kernel's buffer — but TCP will not hand any of them to your application until 3 is retransmitted and arrives to fill the hole. The later data is there, complete, useless, waiting. This is head-of-line (HOL) blocking: one lost packet stalls the whole stream behind it, even data that made it fine.

This matters enormously for what comes next. When you run several HTTP requests over one TCP connection (which HTTP/2 does), a single lost packet blocks all of them, because they share one ordered byte stream — one stall, everyone waits. That limitation is exactly the problem HTTP/3 was built to escape, and it's the payoff we set up in HTTP/1, HTTP/2, HTTP/3. For now, just hold the shape: TCP's ordering guarantee, which is the whole point, is also a source of stalls.

MSS and MTU: why big writes get chopped

You can write() a megabyte to a socket in one call, but a megabyte does not travel as one packet. The physical network has a maximum packet size, the MTU (Maximum Transmission Unit) — on standard Ethernet, 1500 bytes. Subtract the IP header (20 bytes) and the TCP header (20 bytes) and you get the MSS (Maximum Segment Size), the largest chunk of your data per packet: ~1460 bytes on a 1500-byte-MTU link.

So TCP takes your byte stream and slices it into MSS-sized segments to hand to IP. Your one-megabyte write becomes ~700 packets, numbered by their sequence bytes, ACKed and reassembled at the far end into the exact stream you wrote. You never see the seams — that's the abstraction working.

MTU matters when it's wrong. If a segment is bigger than some link along the path allows and it's marked "don't fragment" (which modern TCP does, so it can discover the right size), that packet gets dropped and an ICMP "too big" message is supposed to come back. When a misconfigured firewall eats those ICMP messages — depressingly common — you get PMTUD (Path MTU Discovery) blackholes: small requests work, large responses hang forever, because the oversized packets vanish silently and the sender never learns why. A connection that works for tiny payloads and dies on big ones is the classic fingerprint.

Go deeper

Check yourself

Answer out loud, as if an interviewer asked. If you hand-wave, reread that section.

  1. Walk the three-way handshake with concrete sequence numbers, explaining what ack=1001 in the SYN-ACK actually means. How many round trips of pure setup is this before your first request byte moves?
  2. Two of your machine's TCP connections go to the same server IP and port 443. What makes them different connections, and where does the 'connection' physically live?
  3. A colleague blames a slow 5MB download on bandwidth, but a warm connection to the same server downloads it fast. Explain using slow start and the congestion window why the cold connection is slow even on a fat pipe.
  4. Distinguish flow control from congestion control: which window protects the receiver, which protects the network, and how does TCP even detect network congestion given it gets no direct signal?
  5. Segment 3 of a 6-segment stream is lost; 4, 5, 6 already arrived. Why does your application see none of them yet, what is this called, and why does it make one lost packet stall multiple HTTP/2 requests sharing the connection?
  6. Requests to a server work fine for small payloads but large responses hang forever with no error. What TCP-level mechanism has likely broken, and what MTU-related failure does that fingerprint point to?