How TCP Works: Handshake, Reliability, and Congestion Control

HTTP can describe a request. DNS can find an address. Neither of those steps moves a single reliable byte across an unreliable network. That job belongs to TCP.

This article explains how the Transmission Control Protocol turns IP datagrams — packets that can be lost, duplicated, reordered, or delayed — into a bidirectional byte stream that applications can treat as a connection. The goal is a working mental model: sequence numbers, acknowledgments, windows, retransmission, and why a lost packet can stall more than one HTTP request.

What TCP Actually Provides

TCP is a transport-layer protocol. It sits above IP and below application protocols such as HTTP/1.1 and HTTP/2. From an application's point of view, a TCP connection is a pair of ordered byte streams: one in each direction. The socket API hides segments, retries, and reordering.

TCP promises four properties that raw IP does not:

  • Connection orientation. Peers exchange state before data flows. That state includes initial sequence numbers, window sizes, and later options such as timestamps or window scaling.
  • Reliability. Lost or corrupted data is detected and retransmitted. The receiver delivers bytes in order.
  • Flow control. A fast sender will not overwhelm a slow receiver's buffer.
  • Congestion control. A sender backs off when the path appears overloaded, so one connection does not collapse the shared network.

TCP does not promise a fixed bitrate, a maximum latency, or message boundaries. If you write 100 bytes and then 20 bytes, the other end may read 120 bytes in one call. Message framing is the application's problem.

The current specification is RFC 9293, which incorporates decades of updates on top of the original RFC 793 design.

Where TCP Sits in a Real Request

When a browser loads https://example.com, several layers run in sequence. DNS resolves the name to an IP address. The operating system selects a source port and a route. TCP then establishes a connection to destination port 443. TLS runs on top of that connection. Only then does HTTP send GET /.

HTTP/1.1 and HTTP/2 still use TCP. HTTP/3 does not: it uses QUIC over UDP. That split exists because of properties explained later in this article, especially head-of-line blocking.

Related pieces on this site: what happens when you type a URL, how HTTP's request-response cycle works, how DNS works, and how the TLS handshake encrypts HTTP.

Segments, Not Messages

TCP does not send "files" or "HTTP responses." It sends segments. A segment is a TCP header plus a slice of the outgoing byte stream, wrapped in an IP packet.

Important header fields:

  • Source and destination ports identify the two sockets. Together with the two IP addresses they form a 4-tuple that uniquely names the connection on a host.
  • Sequence number is the byte offset of the first payload byte in this segment (with a special case for SYN, explained below).
  • Acknowledgment number, when the ACK flag is set, is the next byte the sender of this segment expects to receive. Acknowledgments are cumulative: ACK n means every byte before n has arrived.
  • Window is how many additional bytes the receiver is willing to buffer. This is flow control, not congestion control.
  • Flags include SYN, ACK, FIN, RST, PSH, URG, plus ECE and CWR for explicit congestion notification.
  • Checksum covers the TCP header, payload, and a pseudo-header of IP addresses. A bad checksum is treated as a drop.

Sequence numbers count bytes, not packets. If a segment carries 1,460 bytes starting at sequence 5,000, the next new data starts at 6,460. That design lets the receiver splice overlapping retransmissions and detect duplicates precisely.

The Three-Way Handshake

A two-way "hello / hello" exchange is not enough. Each side must advertise its own initial sequence number (ISN) and learn that the other side received it. Sequence numbers are not synchronized to a global clock, and ISNs should be unpredictable to resist sequence-prediction attacks.

The three-way handshake (3WHS) does that with three segments:

  1. Client → server: SYN, sequence = x (the client's ISN). Client state: SYN-SENT.
  2. Server → client: SYN + ACK, sequence = y (the server's ISN), acknowledgment = x + 1. Server state: SYN-RECEIVED.
  3. Client → server: ACK, acknowledgment = y + 1. Both sides move to ESTABLISHED. This third segment may already carry application data.

Why does SYN consume a sequence number even though it carries no payload? Because SYN itself must be reliable. The acknowledgment x + 1 confirms that the SYN, not a data byte, was accepted. FIN uses the same rule later.

On the server, an incoming SYN typically lands on a SYN queue. After the final ACK arrives, the connection moves to the accept queue, where accept() can return a new socket. A flood of SYNs that never complete the handshake is a SYN flood: the SYN queue fills with half-open connections. Modern stacks mitigate this with SYN cookies, which encode state in the server ISN instead of storing a full control block for every SYN.

Reliability: Sequence Numbers, ACKs, and Retransmission

Once the connection is established, each side tracks:

  • the next byte it will send
  • the oldest unacknowledged byte it has sent
  • the next byte it expects to receive

If a segment is lost, the receiver does not ACK past the gap. The sender notices in one of two ways:

  • Retransmission timeout (RTO). The sender estimates round-trip time and sets a timer per outstanding data. When the timer fires, it retransmits the oldest unacked segment and typically reduces its congestion window.
  • Fast retransmit. Duplicate ACKs for the same sequence number usually mean a later segment arrived while an earlier one is missing. After several duplicate ACKs (commonly three), the sender retransmits without waiting for the full RTO.

Selective Acknowledgment (SACK) options let the receiver report non-contiguous blocks that did arrive. Without SACK, a sender that lost one segment in a large window may retransmit more than necessary. With SACK, it can fill only the holes.

TCP delivers a contiguous prefix to the application. Bytes that arrive out of order sit in the receive buffer until the gap fills. That is correct for a single byte stream. It is also the root of transport-level head-of-line blocking.

Flow Control Versus Congestion Control

These two windows are easy to confuse because both limit how much the sender may have in flight. They solve different problems.

Flow control: the receive window (rwnd)

The receiver advertises how much free buffer space it has. That value travels in every ACK as the Window field (and may be scaled by the window-scale option negotiated at handshake time). The sender must not send data beyond the advertised window.

If the application stops reading, the receive buffer fills and rwnd shrinks toward zero. A zero window tells the sender to pause. The sender then probes periodically with a tiny segment so it notices when the window reopens. This protects a slow phone or a blocked thread from being flooded by a fast server.

Congestion control: the congestion window (cwnd)

The network path has no single buffer you can measure directly. Routers drop or mark packets when queues grow. TCP treats loss — or an ECN mark — as a congestion signal.

The sender maintains cwnd, a limit on unacknowledged bytes inferred from those signals. The amount of data it may send is:

flight_size_limit = min(cwnd, rwnd)

Classic Reno-style control has two phases:

  • Slow start. cwnd grows exponentially (roughly doubling each RTT) until it hits ssthresh or a loss occurs. The name is historical; early growth is actually aggressive.
  • Congestion avoidance. After ssthresh, cwnd grows roughly one segment per RTT (additive increase). On loss, ssthresh is cut and cwnd is reduced (multiplicative decrease).

Modern stacks often run CUBIC (common on Linux) or BBR. CUBIC grows the window as a cubic function of time since the last congestion event, which recovers faster on long fat networks. BBR models bottleneck bandwidth and round-trip delay instead of treating loss as the primary signal. The details differ; the idea does not: the sender must share the path.

Closing a Connection

TCP connections are full-duplex. Each direction closes separately.

A four-segment close is typical:

  1. Active closer sends FIN.
  2. Peer ACKs the FIN. That direction is now half-closed. The peer may still send data.
  3. Peer later sends its own FIN.
  4. Active closer ACKs and enters TIME-WAIT.

TIME-WAIT lasts two maximum segment lifetimes (often two minutes). It has two jobs. First, it holds the 4-tuple so a delayed segment from the old connection cannot be accepted by a new connection that reused the same ports. Second, it lets the final ACK be retransmitted if it was lost; otherwise the peer would retransmit FIN forever.

A RST segment aborts the connection immediately. You see resets when a process dies, a firewall rejects a packet, or one side sends data to a socket that is already closed.

A Worked Example

Suppose a client has established a connection. Its next send sequence is 1,000. The server's advertised window is 4,000 bytes. cwnd is 3,000 bytes. Maximum segment size is 1,000 bytes.

The sender may have at most 3,000 unacked bytes. It sends three segments: seq 1000, 2000, and 3000. The middle segment is dropped.

The server receives 1000–1999 and ACKs 2000. It then receives 3000–3999, stores them out of order, and sends another ACK 2000 (a duplicate ACK). After enough duplicate ACKs, the client fast-retransmits seq 2000. The server now has a contiguous range through 3999 and ACKs 4000. The application can read 3,000 new bytes.

If those three segments had carried three different HTTP/2 responses multiplexed on one connection, the second and third responses would still wait in the kernel until byte 2000 arrived. HTTP/2 multiplexes at the application layer. TCP still sees one stream.

Head-of-Line Blocking, HTTP/2, and QUIC

HTTP/2 multiplexes many request/response streams over one TCP connection. That removes HTTP/1.1's need for six parallel connections, but it inherits TCP's in-order delivery. One lost packet stalls every stream on that connection until the retransmission arrives.

QUIC, used by HTTP/3, runs over UDP and implements reliability per stream. A loss on the CSS stream does not freeze the font stream. QUIC also combines transport setup with TLS 1.3, which cuts handshake round trips compared with TCP then TLS.

TCP is not obsolete. Operating systems implement it in the kernel with decades of NIC offload, debugging tools, and middlebox compatibility. Many APIs, load balancers, and databases still speak TCP. QUIC is the response to TCP's stream model, not a claim that reliability is unnecessary.

What Developers Actually Debug

Most application bugs that look like "TCP problems" are one of these:

  • Connection refused. Nothing is listening on that port, or a firewall sent RST.
  • Connection timed out. SYNs never got a SYN-ACK. Routing, packet filters, or a dead host are more likely than a TCP logic bug.
  • Slow first byte. Handshake plus TLS plus server think time. Capture whether time is spent before SYN-ACK, before Server Hello, or after the HTTP request.
  • Stalls under loss. cwnd collapse, bufferbloat, or HOL blocking on a multiplexed connection. Tools: packet captures, ss -ti on Linux (shows cwnd, rtt, and state), browser DevTools protocol waterfall.
  • TIME-WAIT exhaustion on a client that opens and closes many connections per second to the same destination. Connection reuse (HTTP keep-alive, HTTP/2) or expanding the local port range are the usual fixes.
  • Nagle plus delayed ACK. Nagle's algorithm withholds a small write until an ACK arrives or a full segment can be sent. Delayed ACK may wait up to ~40–200 ms before acknowledging a small segment. Together they can add latency to chatty request-response protocols. TCP_NODELAY disables Nagle and is common for RPCs and interactive apps.

TCP Compared with UDP

UDP sends datagrams. There is no handshake, no retry, no ordering, and no congestion control in the protocol itself. DNS queries, real-time media, and games often choose UDP because a late packet is less useful than a fresh one. Those applications then add their own reliability where it matters — or they adopt QUIC, which rebuilds TCP-like features in user space on top of UDP.

Choose TCP when you want a reliable stream and you can live with in-order delivery and kernel-managed congestion control. Choose UDP when you need to define loss behavior yourself. Choose QUIC when you want reliable multiplexed streams without TCP's cross-stream stalls and you can accept a newer protocol stack.

Common Misconceptions

"TCP is slower than UDP." Throughput on a clean path is usually limited by the link and by congestion control, not by the existence of ACKs. UDP without congestion control can appear faster until it causes collapse or gets policed. Latency differs: TCP's handshake and loss recovery add round trips that UDP can skip.

"An ACK means the application read the data." An ACK means the TCP stack accepted the bytes into its receive buffer. The process may not have called read() yet.

"The three-way handshake is what makes TCP secure." The handshake only synchronizes sequence numbers. Encryption and authentication are TLS or another protocol above TCP. SYN cookies and random ISNs help against some spoofing; they are not confidentiality.

"Window size is congestion control." The advertised window is flow control. Congestion control is the sender's cwnd, inferred from the path.

"HTTP/2 fixed head-of-line blocking." It fixed HTTP/1.1's application-level HOL (one response blocking the next on a single connection without multiplexing). Transport-level HOL remains until you leave TCP or accept multiple connections again.

Takeaways

TCP gives applications a reliable, ordered, flow-controlled byte stream over IP. It does that with sequence numbers, cumulative (and optional selective) acknowledgments, retransmission timers, an advertised receive window, and a sender-side congestion window.

The three-way handshake exists to agree on initial sequence numbers. Closing is independent in each direction and leaves TIME-WAIT behind for a reason. One lost segment can freeze every HTTP/2 stream on that connection because TCP has no idea those streams exist.

Once that model is in place, HTTP, TLS, and "why is this API call hanging" become easier to unpack: you know which layer owns reliability, which layer owns encryption, and which layer owns request semantics.

Previous Post