What TCP does after the handshake
The journey of a web request gives step 3's handshake three lines — SYN, SYN-ACK, ACK — and moves on to TLS. But the handshake is just the setup. Everything that makes TCP worth the setup happens afterward, and most of it eventually surfaces in your application as a connection timeout, an ECONNRESET, or a request that's inexplicably slower than the one before it.
What TCP (Transmission Control Protocol) is actually promising
IP is the protocol below TCP: a best-effort protocol. Packets may arrive out of order, arrive twice, or quietly never arrive at all. There is no notion of a conversation — each packet is independent.
TCP's job is to turn that into something you can write code against: an ordered, complete, error-checked stream of bytes between two ports.
UDP (User Datagram Protocol) is the honest comparison: it's IP with port numbers bolted on, and nothing else.
| TCP | UDP | |
|---|---|---|
| Ordering | Guaranteed | None |
| Lost packets | Retransmitted | Gone |
| Connection setup | 1 round trip (handshake) | None — just send |
| Rate control | Flow + congestion control | None |
| Per-packet overhead | 20+ bytes of header | 8 bytes |
| Used by | HTTP/1.1, HTTP/2, SSH, SMTP | DNS, video calls, QUIC (HTTP/3) |
Neither is better. TCP's guarantees cost round trips and buffering, which is exactly the wrong trade when a late packet is worthless to you anyway — a video frame from 200ms ago, for instance.
Sequence numbers and ACKs
The handshake exchanges starting sequence numbers for a reason: everything after it is bookkeeping against those numbers.
Two details worth pinning down. First, sequence numbers count bytes, not packets — send 1460 bytes starting at sequence 1000 and your next segment starts at 2460. Second, that starting number is randomized, not zero.
The receiver's side of the bookkeeping is the ACK number, which means "the next byte I expect" — implicitly acknowledging everything before it. So ack=2460 says "I have everything from 1000 through byte 2459," regardless of how many packets that took.
Now watch what happens when a segment goes missing:
The sender has two ways to notice the loss. The slow one is the retransmission timeout — a timer derived from measured round-trip time, which doubles on each successive failure. The fast one is what's drawn above: three duplicate ACKs mean "packets are still flowing, but there's a hole," which is enough to retransmit immediately without waiting out the timer. This is fast retransmit, and it's why an isolated lost packet on a busy connection costs a fraction of what the timeout would.
SACK refines this further: the receiver reports exactly which byte ranges it holds, so the sender retransmits only the gap instead of guessing.
Flow control — don't overwhelm the receiver
A fast sender talking to a slow receiver will overrun it. TCP's answer is the receive window: every ACK carries a number saying "I have this many bytes of buffer space left." The sender may never have more unacknowledged data in flight than that. When an application stops reading, the buffer fills, the advertised window shrinks to zero, and the sender stops until a window update says otherwise.
This is the part of TCP you have most likely already touched without naming it:
const ok = res.write(chunk) // false = internal buffer is full
if (!ok) {
await once(res, "drain") // wait until it's safe to write again
}
Backpressure is TCP flow control, made visible
When write() returns false, Node is telling you its internal buffer has passed highWaterMark. That buffer is filling because the kernel socket buffer is full, and the kernel socket buffer is full because the peer's receive window has closed — the client on the other end isn't reading fast enough.
write() never blocks: call it as many times as you want and it always returns immediately, even when the bytes can't actually go out yet. Ignore the return value and keep writing anyway, and Node just keeps queuing what you hand it in your server's heap — memory, not the network. Nothing caps that queue. For a large response and a client reading slowly, you end up buffering an unbounded amount of data in memory on behalf of that one slow connection.
pipeline() and pipe() automate this whole dance for you — pausing the source stream the moment the destination signals backpressure, resuming it on drain — which is the real argument for using them over a manual write() loop.
Congestion control — don't overwhelm the network
Flow control protects the receiver. It says nothing about the dozen links along the path, any one of which may be the actual bottleneck. Overrun those and packets get dropped by routers — which, if every sender responded by retransmitting harder, collapses the network for everyone.
So TCP keeps a second limit alongside the receive window: the congestion window, its private estimate of what the path can absorb. The sender is bounded by whichever is smaller.
Receive window (rwnd) | Congestion window (cwnd) | |
|---|---|---|
| Protects | The peer's buffer | The network path |
| Who sets it | The receiver, in every ACK | The sender, by inference |
| How it's learned | Told explicitly | Guessed, adjusted on loss |
| Visible in a packet capture | Yes | No — it's sender-side state |
The crucial part is how cwnd starts. A new connection has no idea what the path can handle, so it begins deliberately small — around 10 segments, roughly 14KB — and doubles every round trip until something breaks. That ramp is called slow start, and it has a consequence worth internalizing:
After slow start, growth turns linear, and a loss cuts the window back sharply. The precise shape of that curve is the algorithm's business — CUBIC is the Linux default, BBR is Google's alternative that models bandwidth and latency directly rather than treating loss as the only signal. For application work, the shape matters much less than the fact that the ramp exists at all.
The connection lifecycle
Opening is a three-way handshake. Closing is a four-way one, because each direction is shut down independently — TCP connections are full-duplex, and one side can stop sending while still receiving.
Two of these states are worth knowing by name, because both show up in production and mean very different things.
TIME_WAIT sits on whichever side closed first, and it's normal. It exists so that the final ACK can be re-sent if it's lost, and so that stray segments from this connection can't be delivered to a new connection that reuses the same four-tuple. It lasts about 60 seconds on Linux. Thousands of them on a busy proxy is expected, not a leak — though it does consume ephemeral ports, and a machine making many short-lived outbound connections can genuinely run out.
CLOSE_WAIT is the one to worry about. It means the peer sent a FIN, the kernel acknowledged it, and now it's waiting for your application to call close(). The kernel can't proceed without you.
Where TCP leaks into HTTP
TCP delivers bytes strictly in order. That guarantee is the whole point, and it's also a problem: if one segment is lost, every segment that arrived after it sits in the kernel buffer, complete and unusable, until the missing one is retransmitted. The application isn't allowed to see past the hole. That's head-of-line blocking, and the history of HTTP versions is largely a history of fighting it.
- HTTP/1.1 — one request at a time per connection. Browsers work around it by opening roughly six connections per origin, which multiplies handshakes and gives each one its own cold congestion window.
- HTTP/2 — one connection, many multiplexed streams. This solves head-of-line blocking at the HTTP layer — requests no longer queue behind each other. But all those streams still ride a single TCP connection, so one dropped packet stalls every stream at once. Under packet loss, HTTP/2 can do worse than HTTP/1.1's six independent connections.
- HTTP/3 — abandons TCP for QUIC over UDP, and rebuilds reliability per-stream, so loss in one stream no longer blocks the others. Since it was designed after TLS rather than around it, QUIC folds the TLS 1.3 handshake into the transport handshake — the two round trips the journey post describes separately collapse into one, or zero when resuming a prior session.
That last point is the neat summary of this whole note: HTTP/3 exists because TCP's guarantees, which are exactly right for a single file transfer, are subtly wrong for a browser fetching a hundred resources at once.
Failure modes you'll actually meet
- Connection timeout vs. read timeout. Different failures that get conflated constantly. A connection timeout means the handshake never completed — wrong host, firewall dropping SYNs, server not listening. A read timeout means the connection is fine and the server is slow. Retrying helps the first; it usually makes the second worse.
ECONNRESET. You received an RST — an abrupt "this connection does not exist." Common causes: the peer process died, the peer calledclose()with unread data still in its buffer, or a load balancer silently reaped an idle connection whose timeout was shorter than your client's keep-alive.EADDRINUSEon restart. The listening socket's four-tuple is still inTIME_WAIT.SO_REUSEADDRis what lets you bind anyway, and it's why most dev servers set it.- Pool exhaustion. Node's
http.Agentcaps sockets per host. OncemaxSocketsis reached, requests queue in your process, invisible to every server-side metric — the request hasn't been sent yet. It looks like a slow upstream, but the wait is entirely local.
Try it yourself
curl can break a request into exactly the phases this note and the journey post describe:
$ curl -w "dns: %{time_namelookup}s\n\
tcp: %{time_connect}s\n\
tls: %{time_appconnect}s\n\
ttfb: %{time_starttransfer}s\n\
total: %{time_total}s\n" -o /dev/null -s https://lamlq.dev
dns: 0.028s
tcp: 0.061s ← handshake done (tcp − dns = 1 RTT)
tls: 0.119s ← encrypted (tls − tcp = TLS handshake)
ttfb: 0.184s ← first byte (ttfb − tls = server work)
total: 0.192s
Each number is cumulative, so the gaps are what matter — and they separate TCP cost from TLS cost from server cost cleanly enough to tell you which one to go fix.
Two more worth running:
# Watch connection states while a dev server runs
$ netstat -an -p tcp | grep 3000 # macOS
$ ss -tan '( sport = :3000 )' # Linux
# Reuse in action: one connection, two requests
$ curl -w "tcp: %{time_connect}s ttfb: %{time_starttransfer}s\n" \
-o /dev/null -s https://lamlq.dev https://lamlq.dev
In that last one, the second request reports a time_connect of essentially zero — same connection, no handshake, and a congestion window that's already done its climbing.
References
- High Performance Browser Networking — Building Blocks of TCP
- RFC 9293 — Transmission Control Protocol
- RFC 5681 — TCP Congestion Control
- Node.js — Stream backpressure
- Cloudflare Learning — What is HTTP/3?
Related
- The journey of a web request — the wider trip this note zooms into at step 3.
- Configuring DNS records — the same treatment for step 2.