TCP: The Reliability Machine
In module 01 you saw that IP delivers packets the way a monsoon courier delivers parcels: usually, eventually, in whatever order, and sometimes not at all. Yet every API call you make at work behaves like a clean, ordered conversation. That gap — chaos below, order above — is TCP, and it is the most useful protocol you will ever debug.
The Goal
By the end of this module you can:
- Explain what TCP adds on top of IP, and name the three things IP gets wrong that TCP fixes
- Draw the 3-way handshake from memory and answer the classic interview question: why three packets, not two or four
- Point at where reliability physically lives — sequence numbers, ACKs, and the retransmission timer
- Separate flow control from congestion control in one sentence each
- Diagnose the three classic failures — refused, timeout, reset — from the error alone, before touching any logs
- Read
netstatoutput on your own machine and explain every connection state you see, including TIME_WAIT
The Lesson
The problem TCP solves
IP makes exactly one promise: “I will try.” Packets get dropped by congested routers, duplicated by retransmitting hardware, and reordered by taking different paths. If you built an API on raw IP, a ₹500 payment request could arrive as ₹50 followed by a lonely 0 two seconds later.
TCP builds an illusion on top of that mess: a reliable, ordered stream of bytes. Your Spring code writes bytes in, the other side reads the same bytes out, in order, exactly once. Everything in this module is the machinery behind that illusion.
One honest paragraph on UDP, because interviewers love it: UDP is the same IP wire without the illusion — no handshake, no ordering, no retransmission, just “fling a packet and hope.” That sounds worse, but it wins whenever a late packet is worth less than no packet. DNS uses it because a lost query is cheaper to re-ask than to manage a connection for. Video calls use it because nobody wants frame 412 retransmitted after you’ve already seen frame 450. Games use it because the enemy’s position from 200ms ago is useless. The rule: TCP when every byte matters, UDP when only the latest data matters.
The 3-way handshake — and why exactly three
Before any data moves, both sides must agree on where their byte numbering starts. That is all the handshake is: exchanging starting sequence numbers and confirming both were heard.
1 · SYN. The client opens the conversation: “let’s talk — my byte numbering starts at X.” It sends a SYN (synchronize) packet.
2 · SYN-ACK. The server replies: “heard you (ACK), and here’s my starting number too (SYN).” One packet does both.
3 · ACK. The client confirms it heard the server’s number. Both sides now agree — connection open.
Done — data flows. Why exactly three? Each side must prove it can both send and receive. Two packets can’t prove the client receives; four would be redundant.
sequenceDiagram
participant C as Client
participant S as Server
C->>S: SYN seq 1000
S->>C: SYN ACK seq 5000 ack 1001
C->>S: ACK ack 5001
Note over C,S: Connection ESTABLISHED both sides
What each packet actually carries:
| Packet | Carries | Means |
|---|---|---|
| SYN | Client’s initial sequence number (say 1000) | “I want to talk. My bytes start at 1000.” |
| SYN-ACK | Server’s initial sequence number (5000) + ack 1001 | ”Heard you, expecting your byte 1001. My bytes start at 5000.” |
| ACK | ack 5001 | ”Heard you too, expecting your byte 5001.” |
Now the interview question, answered properly. A TCP connection is two independent byte streams — client→server and server→client — and each direction’s starting number must be both announced and confirmed. That’s four jobs: client announces, server confirms, server announces, client confirms. Why not four packets? Because the middle two ride together: the server’s SYN-ACK is its own announcement and the confirmation of the client’s, in one packet. Why not two? Then the server would have announced its sequence number but never heard back — it couldn’t know the client received it, so the server→client direction would be unconfirmed. Three is the minimum that confirms both directions. Say it that way and the interviewer stops asking.
Sequence numbers and ACKs — where reliability lives
Once connected, every byte gets a number. Send 100 bytes starting at seq 1001, and the receiver replies ack 1101 — meaning “I have everything up to byte 1100, send 1101 next.” ACKs are cumulative: one ACK can confirm thousands of bytes.
Reliability physically lives in three places on the sender:
- A buffer holding every byte sent but not yet acknowledged — it cannot be freed until the ACK arrives.
- A retransmission timer per outstanding chunk. ACK arrives in time: timer cancelled. Timer fires first: the data is sent again, and the timer doubles for the next attempt.
- The sequence numbers themselves, which let the receiver detect duplicates (seen this byte range, discard) and reorder out-of-order arrivals before handing bytes to your application.
This is the whole trick. No component of the network promises delivery. The sender just refuses to forget until the receiver confirms. When the visualizer below drops a packet, watch that this is literally all that happens: silence, timer fires, resend.
Flow control vs congestion control — two windows, two victims
TCP limits how much unacknowledged data can be in flight. Two separate limits, protecting two separate victims:
| Flow control | Congestion control | |
|---|---|---|
| Protects | The receiver | The network |
| Window | Receive window — advertised by the receiver in every segment | Congestion window — the sender’s own estimate |
| Says | ”My buffer has room for 64 KB, don’t exceed it" | "The network dropped a packet, it’s choking — back off” |
| Set by | The other machine, explicitly | Your TCP stack, by probing (start small, grow until loss) |
The sender always obeys the smaller of the two. And here is the connection you’ve already earned: the in-flight data is managed as a sliding window over the byte stream — left edge slides forward as ACKs arrive, right edge as the window allows. That is literally the pattern from DSA 04 — Sliding Window, running billions of times a second in every TCP stack on earth. You learned it on arrays; the kernel runs it on byte streams.
Teardown — and the TIME_WAIT mystery
Closing is also two directions, each closed independently with a FIN/ACK pair:
sequenceDiagram
participant A as Side closing first
participant B as Other side
A->>B: FIN
B->>A: ACK
B->>A: FIN
A->>B: ACK
Note over A: TIME_WAIT for about a minute
After the final ACK, the side that closed first doesn’t free the socket. It sits in TIME_WAIT (roughly a minute on Windows) holding the port reservation. Why: if that last ACK was lost, the other side will resend its FIN — someone must still be there to re-ACK it. And any stray delayed packets from this connection must die out before the same port pair can be reused, or they’d corrupt a brand-new connection.
This is why netstat shows sockets lingering after your program has clearly exited — not a leak, a safety margin. But it has a real production cost: a server (or load-test client) opening and closing thousands of short connections accumulates thousands of TIME_WAIT sockets, each pinning an ephemeral port. There are only ~16k–64k ephemeral ports. Run out, and new outbound connections fail even though nothing is “down.” The fix is never “disable TIME_WAIT” — it’s stop churning connections, which is the next section.
The three classic failures — the table you’ll use forever
Every connection problem you will ever debug surfaces as one of three errors. They are not interchangeable — each one tells you something different and true:
| What you see | What happened on the wire | What it means |
|---|---|---|
| Connection refused | Your SYN arrived; the OS answered with an RST packet | A machine is alive at that IP and answered you — but nothing listens on that port. App is down, crashed on startup, or you have the wrong port. Fast and honest. |
| Timeout | SYN went out; nothing ever came back; retries expired | Packets are vanishing. Firewall silently dropping, wrong IP, dead host, broken route. Nobody answered at all — slow and silent. |
| Connection reset | Mid-conversation, the other side sent RST | The conversation existed and then the other side gave up: process crashed, was restarted mid-request, or a load balancer killed what it considered an idle connection. |
Burn in the asymmetry: refused means somebody answered. Timeout means nobody did. Refused points at the application; timeout points at the network or the address. This single distinction will save you hours at 2am, because it tells you which team to wake up.
Keep-alive vs new connections
Count the cost of one fresh HTTPS connection: handshake (1 round trip) + TLS negotiation (1–2 more round trips, module 05) before byte one of your actual request. To a server 40ms away, that’s 80–120ms of pure ceremony per request — and a fresh TIME_WAIT corpse on close.
So nobody sane connects per-request. HTTP keep-alive reuses one TCP connection for many requests. And this is exactly what’s already running under your stack: Tomcat (inside your Spring Boot app) accepts TCP connections from clients and keeps them alive across requests; HikariCP holds a pool of already-handshaken TCP connections to PostgreSQL precisely so your repository call never pays handshake cost. Every findById you’ve ever run rode a TCP connection that was opened minutes ago and deliberately kept warm. Module 06 is entirely about tuning this.
See It Move
Step through the handshake, data flow, and teardown below — then hit the mode toggle to replay with a lost packet and watch the retransmission timer earn its keep.
Step through it and notice:
- The SYN-ACK is doing two jobs in one packet — that is the answer to “why three, not four.”
- Data segments carry sequence numbers and come back as ACKs — reliability is just bookkeeping plus a timer.
- In lost-packet mode: nothing detects the loss except silence. The timer fires, the exact bytes go again, the ACK finally lands.
- The teardown ends with one side waiting in TIME_WAIT — the lingering socket you’re about to see in
netstaton your own machine.
Check The Concept
How This Shows Up At Work
- The 2am deploy incident. Service A starts throwing
Connection refusedto service B right after B’s deploy. You now know what that means: B’s machine is reachable and answering — B’s process isn’t listening. It crashed on startup. You check B’s boot logs, not the network, and save an hour of firewall archaeology. - The load-test mystery. A perf test client starts failing with “cannot assign requested address” at ~28k requests, while the server looks idle. That’s ephemeral port exhaustion from connection-per-request churn — thousands of TIME_WAIT sockets. The fix is keep-alive/pooling on the client, and you’re the one who spots it in
netstat. - The interview staple. “Walk me through the 3-way handshake — and why three packets?” is asked in nearly every backend round in India. Most candidates recite SYN, SYN-ACK, ACK. You can explain why the count is three, which is the actual question.
- The reset storm. Payments intermittently fail with
Connection resetafter exactly 60 seconds of pool idleness. The load balancer’s idle timeout is shorter than HikariCP’s — it silently kills connections the pool thinks are healthy. Knowing reset means “the other side gave up mid-conversation” points you straight at idle-timeout mismatch. - Code review. A teammate’s HTTP client creates a new connection per request “to keep things simple.” You can now articulate the real cost — handshake + TLS round trips on every call, plus TIME_WAIT pileup — instead of vaguely saying “use a pool.”
Build This
You’ll watch your own splitease-api accept, hold, and release real TCP connections. One note before starting: in Windows PowerShell, curl is an alias for something else — always type curl.exe.
- Start
splitease-apiso it’s listening on 8080. Then look at the listening socket:
netstat -ano | findstr 8080
TCP 0.0.0.0:8080 0.0.0.0:0 LISTENING 18244
Read it: protocol TCP, listening on port 8080 across all local addresses (0.0.0.0), no remote peer yet (0.0.0.0:0), state LISTENING, and the PID — which should be your Java process (check with tasklist /fi "pid eq 18244").
- Make a request with verbose output and find the handshake:
curl.exe -v http://localhost:8080/actuator/health
* Trying [::1]:8080...
* Connected to localhost (::1) port 8080
> GET /actuator/health HTTP/1.1
...
< HTTP/1.1 200
{"status":"UP"}
The line * Connected to localhost ... port 8080 is printed after the 3-way handshake completes and before a single HTTP byte is sent. Everything above that line is modules 01–03 of this track; everything below is module 04.
- Catch a connection in the act. The health call is too fast to observe alone, so open a second PowerShell window. In window 1, fire 50 requests:
1..50 | ForEach-Object { curl.exe -s http://localhost:8080/actuator/health > $null }
In window 2, while that runs (and again right after):
netstat -ano | findstr 8080
TCP 127.0.0.1:8080 127.0.0.1:52114 ESTABLISHED 18244
TCP 127.0.0.1:52109 127.0.0.1:8080 TIME_WAIT 0
TCP 127.0.0.1:52110 127.0.0.1:8080 TIME_WAIT 0
Read it: each curl picked a random high ephemeral port (52109, 52110…) to talk to 8080. ESTABLISHED is a live conversation. The TIME_WAIT rows are finished conversations in their safety period — note PID 0, because the process is gone but the OS still guards the port pair. Run netstat again after a minute or two: the TIME_WAITs have evaporated.
- Confirm the happy path with PowerShell’s own tool:
Test-NetConnection localhost -Port 8080
TcpTestSucceeded : True
Test-NetConnection does exactly one handshake and reports whether it completed. Now break things.
- Break it — connection refused. Ask a port nothing listens on:
curl.exe http://localhost:9999
curl: (7) Failed to connect to localhost port 9999 ... Connection refused
It fails in milliseconds. Your OS received the SYN to port 9999, found no listener, and answered with an RST instantly. Fast failure = somebody answered.
- Break it — timeout. Ask an address that swallows packets (10.255.255.1 is a private address almost certainly unrouted on your network):
Test-NetConnection 10.255.255.1 -Port 80
It hangs for ~20 seconds, then:
WARNING: TCP connect to (10.255.255.1 : 80) failed
TcpTestSucceeded : False
Feel the difference in your chair: refused was instant, this made you wait. SYNs went out, nothing came back, the stack retried, gave up. Silence = nobody answered. That wait is the signature of firewalls and dead hosts.
- Break it — reset. Start a request, then kill the conversation partner. In window 1, hold a connection open against a slow public endpoint:
curl.exe http://httpbin.org/delay/10
While it waits, in window 2 find and kill curl’s connection from the other direction — or simpler: run the delay request against your own API stand-in by stopping splitease-api (Ctrl+C in its console) while a request like step 3’s loop is mid-flight. Requests in flight die with a reset/connection was aborted style error — the third row of your table: the conversation existed, then the other side vanished mid-sentence.
You have now personally produced all three failures. Next time one appears in production, it’s an old acquaintance.
Where to Practice
| Resource | What to do there | How long |
|---|---|---|
| Cloudflare Learning Center (cloudflare.com/learning) | Read “What is TCP/IP?” and “What is UDP?” — then close them and reproduce the TCP vs UDP table from memory | 25 min |
| httpbin.org | Use /delay/5 with curl.exe -v while watching netstat in another window — observe ESTABLISHED to a real remote IP on port 80 | 15 min |
| curl docs (curl.se) | Read the -v section of the manpage; learn to spot where connect ends and HTTP begins in any verbose output | 15 min |
Check Yourself
- Name the three things IP does to packets that TCP must paper over.
- What does the SYN-ACK packet carry, and why does its double duty explain the number three?
- A sender transmits bytes 5001–6000 and receives
ack 5501. What does the sender know, and what must it keep? - Receive window vs congestion window — who sets each, and who does each protect?
- Why does TIME_WAIT exist, and why does it sit on the side that closed first?
- Connection refused vs timeout: which one proves a machine answered you, and how?
- Why does HikariCP keep TCP connections to PostgreSQL open instead of connecting per query?
- When is UDP genuinely the right choice? Give two real examples and the shared reason.
Answers
-
Packets get lost, duplicated, and reordered. TCP retransmits the lost, discards the duplicates (sequence numbers reveal them), and reorders before delivery — producing a reliable ordered byte stream.
-
The server’s own starting sequence number plus an acknowledgement of the client’s. It’s an announcement and a confirmation in one packet — which is why four logical jobs (announce ×2, confirm ×2) fit in three packets, and why two packets would leave the server’s direction unconfirmed.
-
The receiver has everything up to byte 5500. Bytes 5501–6000 are still unacknowledged, so the sender must keep them buffered with a running retransmission timer until an ACK covers them.
-
Receive window: set by the receiver, advertised in every segment, protects the receiver’s buffer. Congestion window: set by the sender’s own TCP stack by probing for loss, protects the network. Sender obeys the smaller.
-
If the final ACK is lost, the other side resends its FIN — someone must remain to re-ACK it. And delayed stragglers from the old connection must die before the port pair is reused. The first closer is the one who sent that final ACK, so it stands guard.
-
Refused. It’s an RST packet actively sent back by a live OS saying “no listener on that port.” A timeout is pure silence — nothing answered at all.
-
Each fresh connection costs a handshake (plus auth/TLS setup) before any query runs, and leaves TIME_WAIT debris on close. A pool pays that cost once per connection and amortizes it over thousands of queries.
-
DNS queries and live video/games. The shared reason: a retransmitted old packet is worth less than just moving on — only the latest data matters, so TCP’s reliability machinery is pure overhead.
Explain it out loud: Explain to an empty chair why Connection refused after a deploy means you should read the application’s startup logs, while a timeout means you should question the network — going all the way down to which packets were or weren’t sent in each case. If you can’t narrate the RST, re-read the failure table.
Still Unclear?
I understand SYN, SYN-ACK, ACK as a sequence but not WHY two packets would
be insufficient. Walk me through a 2-packet handshake step by step and show
me the exact moment it breaks. Then quiz me on the 4-packet version and make
me argue why it collapses to 3.
Explain TIME_WAIT using my own netstat output: [paste yours]. Why is the PID 0
on those rows? Then describe a production scenario where TIME_WAIT genuinely
causes an outage, and what the correct fix is (and why disabling it is wrong).
My Spring Boot app uses HikariCP to talk to PostgreSQL. Trace one
repository.findById() call at the TCP level: which connection is used, when
was its handshake done, what would happen if a firewall silently dropped
that idle connection five minutes ago? Don't write code — narrate packets.
Why AI Can’t Do This For You
When a fintech API starts failing at 2am, the error in the logs is one of three words — refused, timeout, reset — and the entire diagnosis hinges on knowing which packets did or didn’t move for each. AI can recite handshake theory all day, but it can’t run netstat on your box, can’t see that the PID on port 8080 isn’t your app, and can’t feel the difference between an instant failure and a 20-second hang. The person at the keyboard who has personally produced all three failures reads the situation in seconds.
And the deeper layer: every architectural judgment about pooling, keep-alive, idle timeouts, and retry budgets rests on having internalized what a connection costs. You can’t prompt your way to that instinct — you build it by watching your own sockets appear in ESTABLISHED and linger in TIME_WAIT, which you just did.
Module done? Add it to today’s tracker