DEV Community

Cover image for TCP Isn't Reliable Because It's Magic. It's Reliable Because It Never Trusts the Network.
MANGESH MANDLIK
MANGESH MANDLIK

Posted on

TCP Isn't Reliable Because It's Magic. It's Reliable Because It Never Trusts the Network.

"Reliable, ordered delivery over IP" is a fine one-line summary of TCP, and it's also the kind of definition that tells you almost nothing about why TCP is built the way it is. Why does every connection start with three messages instead of one? What's actually making delivery reliable under the hood? Why are flow control and congestion control treated as two separate mechanisms instead of one? And why can a packet loss rate of a fraction of a percent tank throughput on an otherwise fast connection?

Those questions all have the same root: TCP is built entirely on top of a network layer, IP, that makes no promises at all. IP doesn't guarantee a packet arrives, arrives exactly once, or arrives in the order it was sent. Everything TCP does is machinery built specifically to hide that fact from the application sitting on top of it. I put together an interactive walkthrough of that machinery, handshakes, sliding windows, congestion control, on SeeItFlow, if you'd like to watch it rather than read through it.

A reliable stream, built on a network that promises nothing

TCP's job is to take an unreliable, unordered, best-effort packet network and present something completely different to the application above it: a reliable, ordered byte stream. Sequence numbers identify exactly where each byte belongs in that stream. Acknowledgements tell the sender what actually made it across. Retransmissions recover whatever didn't. Reordering logic handles packets that arrive out of sequence, which happens constantly on a real network. Flow control keeps a fast sender from overwhelming a slow receiver. Congestion control keeps a fast sender from overwhelming the network itself. None of these individually is complicated, but together they're doing the work of turning "packets that might not show up" into "a stream you can trust."

One detail here trips people up constantly: TCP is a byte-stream protocol, not a message protocol. If your application makes two separate write() calls, there's no guarantee the other end sees two corresponding read() calls. TCP doesn't preserve your message boundaries, it just guarantees the bytes arrive in order. Any protocol built on top of TCP, HTTP included, has to invent its own way of marking where one message ends and the next begins, because TCP itself doesn't know or care.

Why the handshake needs three messages, not two

Before any application data moves, TCP establishes state on both ends of the connection:

Client                         Server

   -------- SYN ------------>
   <----- SYN + ACK ----------
   -------- ACK ------------>

          ESTABLISHED
Enter fullscreen mode Exit fullscreen mode

Both sides pick an initial sequence number for their side of the stream and confirm they've received the other's. The reason it takes three messages rather than two comes down to needing both directions verified independently: the client needs confirmation the server actually got its SYN, and separately, the server needs confirmation the client received the server's response. Two messages would leave one direction unconfirmed.

That reliability isn't free, though. A brand-new connection pays a full round trip before any actual application data starts flowing, and on a high-latency path that round trip is real, measurable time spent doing nothing but confirming both sides are listening. This is exactly why connection reuse and connection pooling matter as much as they do in real systems, every new connection you avoid opening is a round trip you don't have to pay for.

Sequence numbers are what make "reliable" actually mean something

TCP's sequence numbers track bytes, not packets. If a sender transmits bytes 1000 through 1999, the receiver's reply of ACK 2000 is saying something specific: everything before byte 2000 has arrived, send byte 2000 next. These acknowledgements are cumulative, so one ACK can implicitly confirm several earlier segments at once, the receiver doesn't need to acknowledge every single segment individually.

This one mechanism is doing a surprising amount of work. It's what lets TCP detect a gap in the stream, discard a duplicate that arrived twice, reconstruct data that showed up out of order, and figure out precisely what needs to be resent when something goes missing. Reliability isn't a separate feature bolted onto sequence numbers, it emerges directly from tracking position in the byte stream this precisely.

Sliding windows: sending more than one thing before waiting

A naively "reliable" protocol could send one segment, wait for its acknowledgement, then send the next. On a high-latency connection that would be painfully slow, most of the time would be spent waiting rather than transmitting. TCP instead allows multiple segments to be in flight, unacknowledged, at once:

Sender                         Receiver

Segment 1  ------------------->
Segment 2  ------------------->
Segment 3  ------------------->
           <------------------- ACK

       window moves forward
Enter fullscreen mode Exit fullscreen mode

This is the sliding window, and how much data TCP is willing to keep in flight at once is governed by two separate mechanisms that get confused with each other constantly, because they sound like they might be the same thing and they're not.

Two different windows, protecting two different things

The receiver advertises a receive window, rwnd, representing how much buffer space it currently has available. If the application reading from the socket is slow to keep up, that buffer fills, rwnd shrinks, and the sender is told, explicitly, to slow down. This exists purely to protect the receiver from being overwhelmed by data it can't process fast enough.

Separately, the sender maintains its own congestion window, cwnd, representing how much traffic it believes the network path can currently handle without breaking down. Packet loss and other congestion signals shrink cwnd, independent of anything the receiver is doing. This exists purely to protect the network itself.

The actual amount of data allowed in flight at any moment is roughly min(rwnd, cwnd), whichever constraint is tighter wins. The distinction worth keeping straight: flow control protects the receiver, congestion control protects the network. They can constrain a connection independently and for completely different reasons, and conflating them makes debugging a slow connection much harder than it needs to be.

Why a little bit of packet loss hurts a lot

TCP treats packet loss as a signal, not just a nuisance to route around. When loss happens, TCP doesn't simply retransmit the missing bytes and move on, its congestion-control algorithm typically also shrinks cwnd, on the assumption that loss might mean the network is congested and sending less aggressively is the safer bet. That means a relatively small loss rate can meaningfully reduce throughput, especially on a high-bandwidth, high-latency path where the window needs to stay large to keep the pipe full, and every loss event knocks it back down.

TCP has two distinct ways of recovering from loss. Fast retransmit kicks in when enough duplicate ACKs arrive to signal a specific segment went missing, and TCP resends it immediately without waiting on any timer. Retransmission timeout is the fallback: if no acknowledgement shows up at all, TCP eventually resends after its retransmission timeout, RTO, expires. That RTO isn't a fixed number, it's calculated dynamically from the observed round-trip time and how much that RTT has been varying. This matters more than it sounds like it should for anyone setting application-level timeouts, an application timeout set too aggressively can give up on a request and abandon it before TCP itself has even had a chance to recover from what might have been a single, transient lost packet.

Bandwidth alone doesn't tell you what a connection can actually do

Picture a very fast link connecting two distant regions. High bandwidth on that link doesn't automatically mean a single TCP connection can use all of it. The concept that actually determines this is the bandwidth-delay product:

BDP = bandwidth × RTT
Enter fullscreen mode Exit fullscreen mode

This approximates how much data needs to be in flight at once to fully saturate the link. If the TCP window is meaningfully smaller than the BDP, the connection becomes window-limited, it's sitting there with unused network capacity available, but it isn't allowed to have enough data in flight to actually use it. Modern operating systems generally auto-tune TCP buffer sizes to account for this, so manually cranking window settings shouldn't be the first move if a connection seems underutilized, measuring the actual RTT and throughput first tells you whether that's even the real bottleneck.

Keep-alive means two different things depending on which layer you're at

The names collide unfortunately here. TCP keep-alive is the transport layer probing an idle connection just to check whether the other end is still reachable at all. HTTP keep-alive, also called persistent connections, is an entirely different, application-layer idea: reusing one already-open TCP connection to carry multiple HTTP requests instead of opening a new one for each. Same phrase, two unrelated mechanisms at two different layers.

Cloud environments add another wrinkle on top of both: load balancers, NAT gateways, proxies, and firewalls all frequently terminate idle connections according to their own independent timeout policies, ones you often don't control and might not even know about until a connection you assumed was still open turns out to have been silently dropped somewhere in the middle. This is exactly why long-lived connections in production often need a combination of application-level connection reuse, TCP keep-alive, and application-level heartbeats working together, no single one of the three covers every layer where a connection can quietly die.

Three things that show up in real production systems

Connection churn and TIME_WAIT. Opening a fresh TCP connection for every small operation means paying a full handshake every single time, and at high enough request rates it also leaves large numbers of sockets sitting in TIME_WAIT, which contributes to ephemeral port pressure and other resource exhaustion. The fix is almost always the boring one: reuse connections instead of constantly opening new ones.

Head-of-line blocking. TCP's guarantee of ordered delivery cuts both ways. If one segment goes missing, none of the bytes that arrived after it can be handed to the application until that gap gets filled in, even if those later bytes have nothing to do with whatever was lost. This exact limitation, blocking at the transport layer regardless of what the application actually needs, is a big part of why HTTP/3 and QUIC were built to give independent streams their own delivery guarantees instead of sharing one ordered stream.

Nagle's algorithm interacting badly with delayed ACKs. Nagle's algorithm batches small writes together to avoid flooding the network with tiny packets, which is genuinely useful for efficiency. But for latency-sensitive applications, that batching behavior can interact with the receiver's delayed-ACK behavior in a way that introduces noticeable, avoidable delay. This is why applications sending frequent small, latency-sensitive messages sometimes explicitly set TCP_NODELAY, but that should be a deliberate, understood trade-off, not a default tuning knob flipped out of habit.

What to actually look at when a request is slow for no obvious reason

Application logs are usually good at telling you a request was slow. They're rarely any good at telling you why, because the actual cause frequently isn't in the application code at all, it's sitting in the transport layer underneath it. The signals worth checking include the RTT distribution, the retransmission rate, connection counts, socket states like TIME_WAIT, and the actual values of rwnd and cwnd at the time. On Linux, tools like ss, tcpdump, Wireshark, and eBPF-based utilities can surface all of this directly.

One distinction here saves a lot of guesswork: a small rwnd points at the receiver or the application reading from the socket not keeping up, while a small cwnd combined with visible retransmissions points at an actual network or congestion problem. Those are different problems with different fixes, and conflating them tends to send people tuning the wrong layer entirely.

TCP vs UDP isn't reliable vs broken

UDP deliberately skips TCP's reliability and ordering guarantees, and that's a design choice, not a shortcoming. For a file transfer, losing bytes silently is unacceptable, so TCP's retransmission behavior is exactly what you want. For live voice, video, or a multiplayer game, a packet that finally arrives after the moment it was relevant for has already passed is often worse than useless, waiting for it to be retransmitted just delays everything behind it for data nobody needs anymore. In systems like that, skipping the lost packet and moving on is frequently the better trade-off, one TCP won't make for you on its own.

So UDP isn't a stripped-down, worse version of TCP. It's a protocol that hands the reliability decision back to the application instead of making it unconditionally. QUIC is a good example of what that flexibility enables: it runs on top of UDP but implements its own sophisticated reliability and congestion control, with independent streams specifically designed to avoid the cross-stream head-of-line blocking that's baked into how TCP works.

The mental model worth keeping

TCP takes IP's unreliable, best-effort packet delivery and turns it into a reliable, ordered byte stream, and it does that entirely through sequence numbers and acknowledgements working together. Sliding windows are what make that reliable stream fast rather than painfully slow, flow control protects the receiver from being overwhelmed, and congestion control protects the network from the same fate, for different reasons and via different mechanisms. Connection setup, retransmissions, packet loss, and connection churn all carry real, measurable latency and capacity costs once you're running this in production, they aren't just theoretical concerns from a networking course. And maybe the most useful habit to take from all of this: when a backend request is mysteriously slow, the application code isn't always where the answer lives. Sometimes the transport layer already told you exactly what went wrong, if you know which signal to check.

References

This post covers TCP's core reliability mechanisms. The full walkthrough on SeeItFlow covers the handshake, sliding windows, and congestion control visually, step by step. There's also a dedicated production engineering guide covering connection churn, keep-alive behavior, and Nagle's algorithm in more depth, and an engineering insights guide focused on debugging real transport-layer problems and the trade-offs behind TCP's design decisions.

Top comments (0)