Embercode
✕
How the Internet WorksLesson 9 of 15

40 min read

TCP: connections, handshakes and retries

Why this matters

A web page is a request and a response. The request is a few hundred bytes, and the response may be megabytes. Between your computer and the server, the data crosses many networks. Parts of it can be lost, delayed or arrive in the wrong order. If a piece of an HTML file went missing silently, the page would be broken in ways that are very hard to debug.

TCP (Transmission Control Protocol) is the layer that prevents this. When a program uses TCP, the operating system takes care of three jobs for it. It sets up a connection with the other side. It numbers and confirms every byte. It sends again anything that did not arrive. Your program only writes bytes in and reads bytes out, and sees them in the right order with nothing missing, or sees an error.

This lesson walks through those three jobs with short traces, real output from a small test on a Linux machine, and the errors you will meet. It does not repeat how packets travel through routers, which the clients and servers lesson covers. The UDP lesson that follows shows the alternative that skips these steps.

The idea: a long letter sent in numbered pages

Imagine you need to send a 20-page letter by post. The post office sometimes loses pages and sometimes delivers them out of order. To make sure the letter arrives intact, you could do three things.

First, before you send anything, you call the recipient and agree that you are both ready. Second, you number every page, and the recipient replies after each batch with "I have everything up to page 6, send page 7 next." Third, if they say they are missing page 4, you post page 4 again.

That is TCP in one picture. Each step has a name in the protocol:

  • The phone call is the handshake, the short exchange that opens a connection.
  • The page numbers are sequence numbers. TCP numbers bytes, not pages, but the idea is the same.
  • The reply "send page 7 next" is an acknowledgement, usually shortened to ACK.
  • Posting page 4 again is a retransmission, or retry.

A connection is shared state between two programs. Both operating systems keep a record of it: the numbers agreed at the start, which bytes were sent, and which were confirmed. The record exists until the connection is closed.

TCP sits on top of IP, the layer that moves individual packets between computers, and below HTTP, the language the web uses for requests and responses. A program that wants a reliable connection asks the operating system for a TCP socket. A socket is the program's handle on one end of a network connection.

Step 1: the handshake

The handshake takes three messages. Here they are in a trace. The numbers are chosen to make the example easy to read. Real systems pick random starting numbers.

client ->  SYN       seq=1000
server ->  SYN-ACK   seq=5000 ack=1001
client ->  ACK       ack=5001

Read each line as a sentence.

  1. The client sends SYN (synchronize). It means "I want to talk, and my first number is 1000." The sequence number tells the other side where to start counting.
  2. The server answers with SYN-ACK. It means "I agree, my first number is 5000, and I got your 1000, so I expect 1001 next." The ack=1001 is the confirmation. A SYN uses up one number, which is why the next expected number is 1001 and not 1000.
  3. The client sends ACK. It means "I got your 5000, so I expect 5001 next." Now both sides know the other's starting number and the connection is open.

Why do both sides choose a starting number? Each side needs to know where the other side begins counting, so that a late packet from an old connection cannot be mistaken for new data. This is why real systems choose a random starting number for each connection.

The operating system does the handshake, not your program. When your program calls connect (in most languages, a function with that name), the operating system sends the SYN and waits. Your program only gets control back when the handshake is finished. On the server side, the program calls listen to say it is ready, and accept to take the next finished connection off a waiting line.

Seeing a real connection

You can watch the same states on your own machine. The test below starts a server on the local address 127.0.0.1 (the computer talking to itself, explained in the clients and servers lesson), connects a client, sends the word hello, and then closes. Between steps it runs ss, a Linux command that lists network sockets and their states.

import socket, subprocess, time
 
PORT = 9001
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
server.bind(("127.0.0.1", PORT))
server.listen()
 
def show(label):
    out = subprocess.run(["ss", "-tan", f"( sport = :{PORT} or dport = :{PORT} )"],
                         capture_output=True, text=True, timeout=10).stdout
    print(f"--- {label}")
    print(out.rstrip())
 
show("after listen()")
client = socket.create_connection(("127.0.0.1", PORT), timeout=5)
conn, addr = server.accept()
show("after connect() and accept()")
client.sendall(b"hello")
print("server received:", conn.recv(100))
client.close()
time.sleep(0.2)
show("after client close()")
conn.close()
server.close()

Output from one run:

--- after listen()
State  Recv-Q Send-Q Local Address:Port Peer Address:Port
LISTEN 0      128        127.0.0.1:9001      0.0.0.0:*
--- after connect() and accept()
State  Recv-Q Send-Q Local Address:Port  Peer Address:Port 
LISTEN 0      128        127.0.0.1:9001       0.0.0.0:*    
ESTAB  0      0          127.0.0.1:9001     127.0.0.1:56374
ESTAB  0      0          127.0.0.1:56374    127.0.0.1:9001
server received: b'hello'
--- after client close()
State      Recv-Q Send-Q Local Address:Port  Peer Address:Port 
LISTEN     0      128        127.0.0.1:9001       0.0.0.0:*    
CLOSE-WAIT 1      0          127.0.0.1:9001     127.0.0.1:56374
FIN-WAIT-2 0      0          127.0.0.1:56374    127.0.0.1:9001

Reading the output:

  • LISTEN is the server socket on port 9001. It waits for new connections. Its Send-Q column shows 128, the length of the waiting line the operating system keeps for connections the program has not accepted yet.
  • After connect() and accept(), there are two ESTAB rows (ESTABLISHED, meaning the handshake is done). One row is the server's end, with port 9001. The other is the client's end, with a port chosen by the operating system. The number 56374 will be different on your machine. Client ports are called ephemeral ports, meaning short-lived ports picked for one connection.
  • After the client calls close(), the server side shows CLOSE-WAIT. This means the other side has closed, and the server program has not closed its own end yet. A server that never calls close() leaves these rows behind, and they add up. You will meet this in the common mistakes section.

What the handshake costs in time

The handshake costs time before any data can move, and that time depends on the distance to the server. Define the round-trip time, or RTT, as the time a message takes to reach the other side and for its reply to come back. A server 80 milliseconds away has an RTT of 80 milliseconds.

Here is what happens to a new connection with that RTT.

  • At time 0, the client sends SYN.
  • At 80 milliseconds, the SYN-ACK arrives. The client sends ACK, and in the same moment it can send the request.
  • At 120 milliseconds, the request reaches the server. It took half a round trip to travel one way.
  • At 160 milliseconds, the server's reply starts to reach the client.

So the first request reaches the server 120 milliseconds after it was started, and the first byte of the answer arrives at 160 milliseconds. The handshake alone costs one full round trip. Secure connections (HTTPS) add more round trips on top, which the browsers lesson covers. Reusing an open connection avoids paying this cost again, and that is why browsers and servers keep connections open for a while.

Step 2: numbers and acknowledgements

After the handshake, data flows. Each byte gets a number, counted from the starting number agreed in the handshake. A sender does not number each message. It numbers each byte, and each packet it sends says "my first byte is number N."

Take the 13-character text HELLO WORLD!! and cut it into chunks of 4 bytes, starting at sequence number 1001 (the first byte after the SYN).

ChunkFirst byte numberBytes
11001HELL
21005O WO
31009RLD!
41013!

The receiver keeps one number, the next byte it expects. It sends that number back as the ACK. If all four chunks arrive in order, the last ACK says 1014, which means "I have everything up to byte 1013, and the next byte I want is 1014."

The receiver does not confirm each chunk separately in the way you might expect from the letter analogy. An ACK is cumulative. It says "everything before this number has arrived," so one ACK covers all the chunks before it. This keeps the number of replies small.

Two things follow from this design.

  • The receiver can always tell which byte is missing, because the ACK names it. If the ACK still says 1005 while later chunks have arrived, then byte 1005 is the gap.
  • The receiver puts data in order before the program reads it. A chunk that arrives early is held in a buffer until the chunks before it arrive. The program never sees bytes out of order.

Step 3: losing a piece and resending

Real networks lose packets, so the next example loses one on purpose. The script below is a toy model. It does not use the operating system's TCP code. It follows the same rules as TCP for numbers and acknowledgements, and it simplifies the timers. The point is to show the flow of the messages.

# Toy model of TCP numbering and acknowledgements. Not the real kernel code.
import random
 
def show(direction, text):
    print(f"{direction:<9} {text}")
 
# Handshake. Each side picks a starting number (here fixed for a readable trace).
client_isn, server_isn = 1000, 5000
show("client ->", f"SYN       seq={client_isn}")
show("server ->", f"SYN-ACK   seq={server_isn} ack={client_isn + 1}")
show("client ->", f"ACK       ack={server_isn + 1}")
print()
 
# Data: the sender numbers every byte. Each segment carries its first byte's number.
message = b"HELLO WORLD!!"
chunks = [message[i:i + 4] for i in range(0, len(message), 4)]
seq = client_isn + 1
segments = []
for c in chunks:
    segments.append((seq, c))
    seq += len(c)
 
lost = {1}  # the second segment is lost on the way
received = {}          # seq -> bytes, the receiver keeps out-of-order data
next_expected = client_isn + 1
 
def receiver_ack():
    return next_expected
 
unacked = {}
for i, (s, c) in enumerate(segments):
    if i in lost:
        show("client ->", f"DATA seq={s} len={len(c)} {c!r}  (lost in the network)")
    else:
        show("client ->", f"DATA seq={s} len={len(c)} {c!r}")
        received[s] = c
    unacked[s] = c
 
# Receiver acknowledges with the next byte it is missing.
def advance():
    global next_expected
    while next_expected in received:
        next_expected += len(received[next_expected])
 
advance()
show("server ->", f"ACK ack={receiver_ack()}  (still missing seq={next_expected})")
for s, c in segments[2:]:
    if s in received:
        advance()
        show("server ->", f"ACK ack={receiver_ack()}")
print()
 
# Timeout: the sender notices no ACK covered the lost segment and resends it.
for s, c in list(unacked.items()):
    if s >= next_expected and s not in received:
        show("client ->", f"RETRANSMIT seq={s} len={len(c)} {c!r}  (after timeout)")
        received[s] = c
advance()
show("server ->", f"ACK ack={receiver_ack()}  (all bytes received)")
print()
print("reassembled:", b"".join(received[s] for s in sorted(received)))
print("bytes sent in total:", sum(len(c) for _, c in segments) + len(segments[1][1]))

Output from one run:

client -> SYN       seq=1000
server -> SYN-ACK   seq=5000 ack=1001
client -> ACK       ack=5001
 
client -> DATA seq=1001 len=4 b'HELL'
client -> DATA seq=1005 len=4 b'O WO'  (lost in the network)
client -> DATA seq=1009 len=4 b'RLD!'
client -> DATA seq=1013 len=1 b'!'
server -> ACK ack=1005  (still missing seq=1005)
server -> ACK ack=1005
server -> ACK ack=1005
 
client -> RETRANSMIT seq=1005 len=4 b'O WO'  (after timeout)
server -> ACK ack=1014  (all bytes received)
 
reassembled: b'HELLO WORLD!!'
bytes sent in total: 17

Walk through the trace.

  1. The sender sends all four chunks without waiting for replies. Chunk 2 (O WO, starting at 1005) is lost.
  2. The receiver got chunks 1, 3 and 4. It can only confirm bytes in order, so it confirms up to 1005, the first byte it is missing. The ACK says ack=1005.
  3. Each later chunk that arrives triggers another ACK, and each one still says 1005. These repeated ACKs are called duplicate ACKs. They tell the sender that something before the newest data is missing.
  4. The sender resends the chunk starting at 1005. This time it arrives. The receiver now has every byte, and its ACK moves to 1014.
  5. The total is 17 bytes sent: the 13 bytes of the message, plus the 4 bytes of chunk 2 sent a second time.

Real TCP does this with more care. Three things the model leaves out matter in practice.

  • Fast retransmit. After three duplicate ACKs in a row, the sender resends the missing chunk straight away, without waiting for the timer. The rule is in RFC 5681, the document that defines TCP congestion control.
  • Timeouts that adapt. The timer is not fixed. The sender measures the round-trip time of its own packets and sets the wait from those measurements. RFC 6298 sets the first wait, for a new connection, to one second. If that wait passes without an ACK, the sender waits twice as long for the next try, and so on.
  • Windows. The sender does not send an unlimited amount before getting an ACK. The receiver says how much buffer space it has, and the sender keeps that much data in flight. This is how TCP avoids flooding a slow receiver.

The model shows the core idea, which is enough to read a packet trace or to reason about a slow or broken connection.

Worked example: a stream has no message edges

TCP gives the program a stream. A stream is a single line of bytes with no markers showing where one write ends and the next begins. The program decides where its messages start and end.

import socket, threading
 
srv = socket.socket()
srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
srv.bind(("127.0.0.1", 9004))
srv.listen()
 
def closer():
    c, _ = srv.accept()
    c.sendall(b"partial")
    c.close()
 
threading.Thread(target=closer).start()
cl = socket.create_connection(("127.0.0.1", 9004))
print("read 1:", cl.recv(100))
print("read 2:", cl.recv(100))
cl.close()
srv.close()

Output from one run:

read 1: b'partial'
read 2: b''

Two things to notice.

  • The first recv returned the bytes that were sent. It could have returned fewer bytes. A recv(100) asks for at most 100 bytes, and TCP can deliver them in pieces. A program that expects a whole line must keep reading until it finds the line ending.
  • The second recv returned an empty bytes value, b''. In this case, that means the other side closed the connection and there are no more bytes. An empty result does not mean "nothing has arrived yet." When data is late, recv waits. An empty result from recv means the stream has ended.

The UDP lesson shows the opposite case, where each send arrives as one separate message.

Connection refused and timeouts

Two errors come up often when a program tries to connect. They mean different things, so it helps to know the difference.

The first test connects to port 9 on the local machine. Nothing is listening there.

import socket
 
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.settimeout(5)
try:
    s.connect(("127.0.0.1", 9))
    print("connected")
except OSError as e:
    print(type(e).__name__, "-", e)
finally:
    s.close()

Output:

ConnectionRefusedError - [Errno 111] Connection refused

This error is fast. The local operating system received the SYN, found no program on that port, and answered with a RST (reset) packet. A reset means "no one is here, do not wait." The computer was reachable. The program at that address was not running, or was not listening on that port.

The second test connects to an address that does not answer.

import socket
 
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.settimeout(2)
try:
    s.connect(("10.255.255.1", 80))
except OSError as e:
    print(type(e).__name__, "-", e)
finally:
    s.close()

Output:

TimeoutError - timed out

Here nothing came back at all. The SYN was sent, the program waited two seconds, and gave up. Possible causes are a wrong address, a machine that is switched off, a firewall that silently drops packets, or a route that does not exist. A refused connection means the machine answered. A timeout means no answer came back in time, and the cause could be anywhere along the path.

Common mistakes

1. Thinking connect proves the server program works. The operating system finishes the handshake even when the program never calls accept. The connection waits in the line shown by the Recv-Q column of the LISTEN row. A program that is stuck and never accepts will still show ESTAB rows for the waiting connections. If a client connects but gets no reply, check whether the server program is reading from the socket.

2. Reading one recv as one message. Stream data arrives in pieces of any size. A server that expects GET /index.html\n may get GET /ind in one read and ex.html\n in the next. The fix is to read until the end marker, or to send the length of the message first.

A program that parses a half-read message as if it were complete fails like this. The first read stopped in the middle of a JSON message, and the parser reports the problem:

import json
 
buf = b'{"name": "ad'   # the first read stopped in the middle of the message
try:
    json.loads(buf)
except json.JSONDecodeError as e:
    print(type(e).__name__, "-", e)
JSONDecodeError - Unterminated string starting at: line 1 column 10 (char 9)

3. Treating an empty read as "no data yet." An empty result from recv means the other side closed. A loop that stops on an empty read is correct. A loop that keeps going forever on an empty read burns CPU, because the read returns immediately each time.

4. Leaving sockets open. A program that closes the connection on its side but forgets the server side will show CLOSE-WAIT rows in ss. A server that never calls close leaves one row per client. Each open socket uses one of the limited file slots the operating system gives each program, so a leaking server eventually cannot open new connections. Use a with block or a finally block to close sockets even when an error happens.

5. Reading an ACK as proof the program got the data. An ACK means the other computer's TCP layer received the bytes. It does not mean the program on the other side read them, parsed them or acted on them. An application that needs to know a request was handled must send a reply at the application level, such as an HTTP response.

Practice

Exercise 1. A client's first SYN has seq=200. What number should the server put in ack in its SYN-ACK?

Show the answer

It should be ack=201. The SYN uses up one sequence number, so the next byte the client expects from the server's side is the one after the SYN.

Exercise 2. A receiver has received bytes 1001 to 1004 and bytes 1009 to 1012, but not bytes 1005 to 1008. Which ACK number does it send?

Show the answer

It sends ack=1005. The ACK names the first byte it is missing. Bytes 1009 and later can be held in the buffer, but the ACK can only move forward past 1008 once those bytes arrive.

Exercise 3. A server is 80 milliseconds away (RTT 80 ms). The client opens a new connection and sends its request as soon as the handshake finishes. How long after the client starts the SYN does the server receive the request? Ignore HTTPS and any processing time.

Show the answer

It takes 120 milliseconds. The SYN takes 40 milliseconds to reach the server, and the SYN-ACK takes 40 milliseconds to come back, so the handshake takes 80 milliseconds. The ACK and request then take another 40 milliseconds to reach the server, which is 120 milliseconds in total.

Exercise 4. In the toy model, suppose chunks 2 and 4 are both lost (the bytes O WO at 1005 and ! at 1013). How many bytes does the sender transmit in total, counting resends? Assume each lost chunk is resent once.

Show the answer

The sender transmits 18 bytes. The message is 13 bytes. The lost chunks are 4 bytes (O WO) and 1 byte (!), and each is sent once more. That is 13 + 4 + 1 = 18 bytes.

Exercise 5. A program downloads a 50 MB file. Should it use TCP or UDP? Give one reason.

Show the answer

It should use TCP. A file with a missing piece is damaged, and TCP resends lost bytes and delivers them in order. UDP would require the program to write its own resend logic, which is the work TCP already does.

Summary

  • TCP opens each connection with a three-message handshake: SYN, SYN-ACK and ACK. The operating system does this before the program gets a connected socket.
  • TCP numbers every byte. The receiver's ACK names the next byte it is missing, so the sender knows exactly what to resend.
  • A lost chunk shows up as repeated ACKs with the same number. The sender resends the missing chunk after a few repeated ACKs or after a timeout.
  • A new connection costs one round trip before the first request can be sent. Secure connections add more.
  • The program sees a stream of bytes, not messages. Reads can return partial data, and an empty read means the other side has closed.
  • "Connection refused" means the machine answered and no program is listening. "Timed out" means no answer came back at all.

Stuck? Ask the tutor

Ask about anything in this lesson. Answers are short and use simple words.

Sign in to ask the tutor.

← JSON APIs and REST basics