Old-School Networking / 2026 ・ No.06

Old-School Networking

Part 6: TCP — ports, the three-way handshake, retransmission, windows and congestion

Published

The Wireshark window. The list shows the ten packets of one submission: SYN, SYN, ACK, ACK, POST /api/expenses, ACK, 201 Created, ACK, two FIN, ACKs and a final ACK. Packet 1, the SYN, is selected with Transmission Control Protocol expanded: Flags: 0x002 (SYN), Window: 64240, and under Options, Maximum segment size: 1460 bytes, SACK permitted, Timestamps and Window scale: 10 (multiply by 1024)
The TCP exchange for one submission. Open packet 1, the SYN (“I'd like to connect”), and it lists what the PC asks for, such as the largest segment it can take (MSS 1460 bytes) and a window multiplier (×1024) (Recorded on 25 October 2026 on a practice network the author built in the cloud and opened in Wireshark 4.2.2. The bar across the top of the window and the headings were added for the illustration.)

In part 5, packets travelled from router to router by their destination address. But packets get lost on the way, or arrive out of order. From this part we're in layer 4, the transport layer.
TCP gets the submission there reliably through loss, reordering, slow receivers and congested roads. We build a “mischief wire” that drops packets on purpose and check each mechanism in turn.

1

The room-number layer

AraiThe 40418 and 3000 in last time's NAT table. You said they were room numbers.

FuruiRight. The address gets you to the building. Which room, which program, from there on is one of TCP's jobs.

Arai“One of”? There are others?

FuruiRouters that drop packets, roads that shuffle them, servers that read slowly, crowded links. IP looks after none of that. TCP takes it all on. Today we cause every one of them on purpose.

The 12-part route map (you are here: part 6)

1 Big picture 2 Physical 3 Data link 4 IP addresses 5 Routers 6 TCP 7 UDP, sockets 8 TLS 9 DNS 10 HTTP 11 Dev networks 12 Troubleshoot You are here Overview Layer 1 Layer 2 Layer 3 Layer 4 Layers 5-6 Layer 7 Practice
  • Port numbers — an address is a building, a port a room; a connection is four values
  • Greetings and numbering — the handshake, Seq and Ack, refused vs. left waiting (measured)
  • Retransmission — drop one packet, swap two (measured)
  • Windows and congestion — a receiver that won't read, and how much is in flight on a crowded link (measured)
  • Goodbyes — FIN, TIME-WAIT, RST

This part's practice network puts a small program the author wrote between the PC (192.168.1.10) and the server (203.0.113.20). It relays traffic and can delay it, limit bandwidth, drop chosen packets and reorder them: a “mischief wire”. Unless stated otherwise it adds 20 ms each way (40 ms round trip). Part 5's routers and NAT are left out to keep things focused.

2

Port numbers

An address is a building, a port is a room

PC 192.168.1.10 35594 submit page 35596 list page 41230 another app Server 203.0.113.20 3000 expenses API 4000 slow server 22 ssh One connection = “address:port” on both ends, four values 192.168.1.10 : 35594 ⇄ 203.0.113.20 : 3000 Same server, same port: a different PC port makes a different connection

A port number is 16 bits: 0 to 65535. Server programs listen on a fixed port, and the PC borrows a free one each time it connects.

# Ports the server is listening on (LISTEN)
srv$ ss -tln
    State Recv-Q Send-Q Local Address:Port Peer Address:PortProcess
    LISTEN 0 128 0.0.0.0:4000 0.0.0.0:*
    LISTEN 0 511 0.0.0.0:3000 0.0.0.0:*
# The range the PC picks its own port from (Linux default)
pc$ sysctl net.ipv4.ip_local_port_range
    net.ipv4.ip_local_port_range = 32768 60999

On the server, the Node.js expenses API waits on 3000 and the “slow server” made for this part on 4000. On the PC side, Linux picks from 32768–60999 (Windows and Mac use 49152–65535). This submission got 35594.

PortTypically
22SSH
80 / 443HTTP / HTTPS (parts 8 and 10)
53DNS (part 9; mostly UDP)
3306 / 5432 / 6379MySQL / PostgreSQL / Redis
3000 / 5173 / 8080common for development servers (a habit, not a rule)

A connection is identified by four values: the address and port at each end. Two browser tabs on the same PC connecting to port 3000 on the same server are two connections, because the PC-side ports differ. If you see “Address already in use” while developing, something is already listening on that port.

3

Start with a greeting

Before sending any data, TCP sets up a connection with three greetings (the three-way handshake): “I'd like to connect” (SYN), “Go ahead, and so would I” (SYN+ACK), “Got it” (ACK). Here is one submission.

# One submission (20 ms each way; Seq and Ack counted from 0, as Wireshark shows them)
    0.000  pc  → srv  SYN      Seq=0    Ack=0    Len=0
    0.040  srv → pc   SYN+ACK  Seq=0    Ack=1    Len=0
    0.041  pc  → srv  ACK      Seq=1    Ack=1    Len=0
    0.041  pc  → srv  PSH+ACK  Seq=1    Ack=1    Len=198  POST /api/expenses
    0.081  srv → pc   ACK      Seq=1    Ack=199  Len=0
    0.090  srv → pc   PSH+ACK  Seq=1    Ack=199  Len=203  201 Created
    0.090  pc  → srv  ACK      Seq=199  Ack=204  Len=0
    0.090  pc  → srv  FIN+ACK  Seq=199  Ack=204  Len=0
    0.132  srv → pc   FIN+ACK  Seq=204  Ack=200  Len=0
    0.132  pc  → srv  ACK      Seq=200  Ack=205  Len=0
# the first three are the greeting, the last three the goodbye

One submission: greeting, request and reply, goodbye (measured times)

PC Server SYN 0 SYN+ACK 40 ms ACK, POST (198 B) ACK 81 ms 201 (203 B) 90 ms FIN FIN+ACK 132 ms ACK Greeting 1 round trip Request, reply 1 RTT + 9 ms work Goodbye 1 round trip

The cover photo has that first SYN opened. A SYN also carries requests. MSS 1460 means “keep each segment's contents to 1460 bytes”: part 2's MTU of 1500 minus 20 bytes each for the IP and TCP headers. The two sides also agree here on SACK (reporting exactly which ranges arrived), timestamps and the window scale.

# curl's timings: 1 round trip to connect, 2 to the first byte of the reply
pc$ curl -w '…' -X POST … http://203.0.113.20:3000/api/expenses
    connect 0.0407 s   first byte 0.0900 s   total 0.0901 s   local port 35594
# the PC closed first, so it keeps a TIME-WAIT for 60 seconds
pc$ ss -tano dst 203.0.113.20
    TIME-WAIT 0 0 192.168.1.10:35594 203.0.113.20:3000 timer:(timewait,59sec,0)

curl's numbers show one round trip (0.041 s) to connect and two (0.090 s) to the first byte of the reply. The greeting alone costs a round trip. With a server 100 ms away, that's 0.1 s before anything happens. So reusing one connection is faster.

# Two submissions, connecting each time
    0.0827 s + 0.0824 s = 0.1652 s
# Two submissions over one reused connection (connects 0 the second time)
    0.0825 s + 0.0421 s = 0.1246 s
POINT

The second submission took 0.042 s over a reused connection and 0.082 s with a fresh one: exactly one round trip apart. HTTP keep-alive and database connection pools exist to skip this greeting (part 10).

4

Numbering the bytes

AraiThe capture says Seq=199 and Ack=204. Does that mean packet number 199?

FuruiNot packets: bytes. TCP treats data not as a series of packets but as one long stream of bytes.

Seq: “this data starts at byte N”. Ack: “send byte N next”

Numbering of the bytes the PC sends 0 SYN POST /api/expenses (198 bytes) 1 198 199 FIN Seq Ack=200 (FIN counts) → Reading them Seq=1 Len=198 198 bytes starting at byte 1 Ack=199 Got up to 198; send 199 next Ack=204 Same on the server's side (203-byte reply) SYN and FIN use one number each. Real numbers start at a random value

The PC's POST is 198 bytes, numbered 1 to 198. The server's Ack=199 means “I have everything up to 198; send 199 next”. The server's 203-byte reply has its own numbering, and the PC answered Ack=204. Each direction has its own sequence.

Wireshark starts them at 0 for readability, but real sequence numbers start at a random value (Sequence Number (raw) in the photo's details). That stops stray packets from an old connection slipping in, and stops outsiders guessing the numbers and injecting forged packets.

NOTE

TCP doesn't keep message boundaries. Send 198 bytes in one go, and there's no guarantee one read on the other side returns exactly those 198 bytes: they may be split, or merged with what follows. That's why HTTP writes the length in Content-Length. When you use sockets yourself, agree on lengths or delimiters (part 7).

5

Refused, or left waiting

A greeting can fail in two ways. Connect to a port where nothing is listening and the server's OS sends back an RST (nobody here). If the SYN is dropped on the way, nothing comes back at all. We had the mischief wire drop SYNs to port 3002 and compared.

# To closed port 3001: the server answers RST (nobody here); refused in one round trip
pc$ curl http://203.0.113.20:3001/
    curl: (7) Failed to connect to 203.0.113.20 port 3001 after 40 ms: Couldn't connect to server
# To port 3002, where the wire drops SYNs: retries continue, and curl gives up at 20 s
pc$ curl --connect-timeout 20 http://203.0.113.20:3002/
    curl: (28) Failed to connect to 203.0.113.20 port 3002 after 20002 ms: Timeout was reached
# times the SYN was sent (s)
    0.0  1.0  2.0  3.0  4.1  5.1  7.1  11.3  19.5

Refusal is instant; silence keeps you waiting

Closed port (3001): refused at once RST after 0.041 s (nobody here) No answer (3002): you wait 0 s 5 s 10 s 15 s 20 s curl gives up (20 s) SYN resends: five at 1 s intervals, then doubling to 2, 4, 8 s “Refused”: you reached the host. “Timed out”: lost or blocked on the way By default Linux gives up after about 2 minutes (131 s)

The refusal took 0.041 s: one round trip. With no answer, the PC resent the SYN every second, then doubled the gap to 2, 4 and 8 seconds. This Linux (6.18) spaces the first few one second apart (tcp_syn_linear_timeouts = 4) and by default gives up after about two minutes.

POINT

Connection refused: you reached the machine, but nothing runs on that port. Timed out: lost on the way, or at a firewall. The error message tells you where to look (part 12).

6

Lost? Send it again

We set up an upload of a 1 MB receipt image with the submission, limited the mischief wire to 10 Mbit/s, and dropped only the 40th data segment from the PC.

# Uploading a 1 MB receipt image, the wire drops the PC's 40th data segment (10 Mbit/s, 20 ms each way)
wire: up drop data#40
# The PC-side capture (numbers are Wireshark's packet numbers)
    58  0.1282  pc → srv  Seq=55169 Len=1448
   123  0.1759  srv → pc  Ack=55169  SACK 56617-58065
   128  0.1771  srv → pc  Ack=55169  (Dup ACK #1)
   130  0.1783  srv → pc  Ack=55169  (Dup ACK #2)
   131  0.1784  pc → srv  Seq=55169 Len=1448  (Fast Retransmission)
# Time taken: three runs without loss, one with
    1.007 s  1.007 s  1.006 s   /   1.008 s
Two parts of a Wireshark packet list stacked. Top: packets 55 to 61, with packet 58, Seq=55169, selected. Bottom: packets 122 to 140, where TCP Dup ACK 123#1 to #7 from the server appear in red on black, and packet 131 in between, a TCP Fast Retransmission of Seq=55169, is selected
Uploading a receipt image while the mischief wire drops just packet 58. The server keeps repeating Ack=55169, and the PC resends Seq=55169 as packet 131 (Recorded on 25 October 2026 on a practice network the author built in the cloud and opened in Wireshark 4.2.2. The bar across the top of the window and the headings were added for the illustration.)

Repeated Acks mean “lost”: send it again

PC Server dropped Seq=55169 Seq=56617 Seq=58065 Seq=59513 Ack=55169 + SACK Dup ACK #1 Dup ACK #2 Seq=55169 Fast Retransmission Three identical Acks mean “lost”: resend without waiting for a timeout Resent after 0.050 s (~1 RTT); total 1.01 s, same as without loss

Seq=55169 never arrives, so each later segment makes the server repeat the same Ack, “still missing from 55169”, with a SACK listing what did arrive. After three identical Acks, the PC resent just 55169 without waiting for a timeout (Fast Retransmission): 0.050 s after the loss, about one round trip. The whole upload took 1.008 s, barely different from three runs without loss (1.006–1.007 s).

Each segment carries 1448 bytes: MSS 1460 minus the 12-byte timestamps option. If no later segments follow, so the repeated Acks never come, TCP waits for a timeout (RTO) instead. Linux's minimum timeout is 0.2 s, so that case is noticeably slower.

When the order changes

# The wire delays the 40th segment by one place. Server-side capture:
    76  0.1375  pc → srv  Seq=56617 Len=1448  (arrived ahead of 55169)
    77  0.1376  srv → pc  Ack=55169  SACK 56617-58065
    78  0.1388  pc → srv  Seq=55169 Len=1448  (Out-Of-Order)
# The later segment arrives first; the server says “still missing from 55169, but I have 56617–58065”. Then 55169 arrives and fills the gap
# Retransmissions by the PC: 0

This time the 40th segment wasn't dropped, just moved back one place. The server put 56617, which arrived first, aside and replied “still missing 55169”, but 55169 arrived right after and filled the gap. The PC resent nothing. The app (Node.js) only ever sees the bytes back in order, so it never knows anything was swapped.

7

The receiver's window

AraiWhat if the sender sends flat out and the receiver can't keep up?

FuruiEvery reply says how much more the receiver can take. That's the window. I've made a server that slacks off reading.

# Sending 1 MB to a server that doesn't read for 3 seconds (32 KB receive buffer)
    48  0.163  srv → pc  Win=0  Ack=40689  (ZeroWindow)
# The buffer fills: window 0. The PC peeks at intervals: “ready yet?”
    49  0.414  pc → srv  Len=0  (probe)
    51  0.966  pc → srv  Len=0  (probe)
    53  2.022  pc → srv  Len=0  (probe)
# After 3 seconds the server starts reading and the window opens
    55  3.082  srv → pc  Win=36200  (Window Update)
    curl: total 4.188 s
A Wireshark packet list. Packet 46 is TCP Window Full and packet 48, selected, is TCP ZeroWindow (Win=0). Then TCP Keep-Alive packets from the PC alternate with TCP ZeroWindow replies at 0.41, 0.97 and 2.02 s, and packet 55 at 3.08 s is a TCP Window Update (Win=36200), after which data flows again
Sending 1 MB to a slow-reading server. At packet 48 the window hits 0 and the PC probes now and then (Wireshark labels them Keep-Alive, but they are checking whether the window has opened). At packet 55 it opens (Recorded on 25 October 2026 on a practice network the author built in the cloud and opened in Wireshark 4.2.2. The bar across the top of the window and the headings were added for the illustration.)

When the receive buffer fills, the window drops to 0

Server's receive buffer (32 KB) full (40,688 bytes) arriving app not reading Window = free space. Now 0 (“stop sending”) window 0 0.41 0.97 2.02 3.08 0.16 opens dots = PC's probes (s) Receive window: how much more the receiver can take, in every reply At 0 the sender waits, so a fast sender can't swamp a slow receiver

The server on port 4000 reads nothing for 3 seconds after connecting. Its 32 KB receive buffer filled at 40,688 bytes, and at 0.163 s the window hit 0 (ZeroWindow). The PC stopped sending and probed at growing intervals, 0.41, 0.97 and 2.02 s: “open yet?” At 3.08 s the server started reading, the window opened and sending resumed.

The window field is 16 bits, so on its own it can only say 64 KB. The SYN agrees a multiplier (Window scale: 10 in the cover photo means ×1024). This flow control keeps a fast sender from swamping a slow receiver. When Node.js's write() returns false, this window is ultimately behind it.

8

Congestion on the road

The window is about the receiver. There's also the road in between. Send too much down a thin link and packets overflow the queue in front of a router and are dropped. TCP infers how crowded the road is from the losses and adjusts how much it sends (congestion control).

With the mischief wire at 10 Mbit/s and a queue of at most 30 packets, we downloaded a 2 MB CSV from the server. The chart counts, every 10 ms from the server-side capture, the segments sent but not yet acknowledged.

Double, overflow, cut back, grow slowly again (measured)

0 25 50 75 100 0.0 s 0.5 s 1.0 s 1.5 s what the wire and queue hold (~65) Slow start doubles each RTT (10→20→40) overflow: 4 dropped count swells during recovery cut to ~70%, then grow slowly cubic congestion control Segments sent but not yet acknowledged 10 Mbit/s, 20 ms each way; counted every 10 ms at the server (2026-10-25)
# Downloading a 2 MB CSV (10 Mbit/s, 20 ms each way, at most 30 packets queued at the wire)
# Segments the server sent in each round trip (41 ms)
     10   20   40   51   36   36   35   36  …
# The server mid-transfer (ss -ti; cwnd is in segments)
    cubic  mss:1448  cwnd:56  ssthresh:46  rtt:66.713 ms  minrtt:40.738 ms
    queue drops 4  retransmissions 4   curl: 1.884 s, 8.90 Mbit/s

The first round trip sent 10 segments, the next 20, then 40: doubling every round trip, called slow start (the initial 10 comes from RFC 6928). When the link and queue could hold no more, four overflowed; after resending them the server cut its sending to about 70 percent (ssthresh 46), then grew slowly. That's cubic, Linux's default.

Note the gap between rtt:66.7 and minrtt:40.7 mid-transfer. Waiting in the queue added 26 ms to the round trip. Queues so deep that delay balloons are called bufferbloat. Some schemes, like Google's BBR, don't wait for losses but read congestion from rising round-trip times.

9

Saying goodbye

The last three packets of the submission are the goodbye. The PC sent FIN (nothing more to send), the server answered with its own FIN, and the PC finished with an ACK. Each direction is closed separately, so it's really four messages, but the server's ACK and FIN went together in one.

The side that closes first keeps a TIME-WAIT state for 60 seconds (the timer:(timewait,59sec) above). It's there to answer again if the last ACK is lost, and to stop late packets from an old connection leaking into a new one with the same ports. Programs that open huge numbers of short connections can pile up tens of thousands of TIME-WAITs and run out of PC-side ports: another reason to reuse connections.

An abrupt end with no goodbye is an RST. Besides connecting to a port nobody listens on (section 5), you also get one when the program at the other end crashes. Connection reset by peer means an RST came from the other side.

10

Checking your own PC

To seeMacWindowsLinux
Connections and statesnetstat -an -p tcpnetstat -anss -tan
What's using a portlsof -i :3000netstat -ano | findstr :3000ss -tlnp
Can I reach a port?nc -vz host 443Test-NetConnection host -Port 443nc -vz host 443
How long connecting tookcurl -o /dev/null -s -w '%{time_connect} %{time_starttransfer}\n' URL

Run netstat -an with a few websites open and you should see a row of ESTABLISHED connections to port 443, with recently closed ones in TIME_WAIT.

§ — On one page, and next time

  1. A port is a room. A connection is four values: address and port at each end. Linux picks PC-side ports from 32768–60999.
  2. The greeting is SYN, SYN+ACK, ACK: one round trip to connect. Reusing the connection cut the second submission from 0.082 s to 0.042 s.
  3. Seq and Ack number bytes. Ack says which byte to send next. TCP keeps no message boundaries.
  4. A closed port was refused with an RST in 0.041 s; dropped SYNs were resent every second while we waited.
  5. One lost segment produced repeated Acks and a resend about one round trip later; total time barely changed. Reordering caused no resends.
  6. A receiver that doesn't read sends window 0. The sender waits, probes, and resumes when it opens.
  7. Sending grew 10, 20, 40, then was cut to about 70% after overflow. The queue added 26 ms to the round trip.

AraiTCP is incredibly thorough. Why not use it for everything?

FuruiSometimes thoroughness gets in the way. On a video call, you don't want a frame from a second ago resent. Next time: UDP, which neither greets nor resends, and sockets, the doorway programs use to reach both.

References

The recordings were made on 25 October 2026 on a practice network the author built with network namespaces on Linux (Ubuntu 24.04, kernel 6.18) in the cloud. The “mischief wire” between the PC and the server is a small Python program (using AF_PACKET) that relays frames between two interfaces and can be told the delay, bandwidth, queue length, and which packets to drop or reorder. The server is Node.js, the slow-reading server is Python, congestion control is cubic, and segmentation offloads (TSO/GRO) were turned off to keep the captures readable. Every number in the text is a real value read from those recordings and the commands' output. The characters are fictional.

Old-School Networking, part 6 — TCP / 2026.10

← Back to all posts