Chapter 23 — TCP part 3: closing a connection, and the states
In one sentence
Closing is a polite four-packet goodbye (each side closes its own direction), and the connection then sits in TIME_WAIT for about a minute to catch stragglers.
How closing works
TCP connections are really two independent one-way streams, so each direction is closed separately.
CLIENT SERVER
| |
| 1. FIN "I'm done sending." |
|--------------------------------------------->|
| |
| 2. ACK "Got it." |
|<---------------------------------------------|
| (server may still be sending!) |
| |
| 3. FIN "Now I'm done too." |
|<---------------------------------------------|
| |
| 4. ACK "Got it." |
|--------------------------------------------->|
| |
| [ TIME_WAIT for ~60 seconds ] [ CLOSED ]
| then CLOSED |
Often it's only three packets, because the server combines its ACK and FIN into one.
There's also the impolite version: RST (reset). One packet, no negotiation, connection dead immediately. Any buffered data is lost. You'll see "Connection reset by peer" at the application.
TIME_WAIT: why your machine holds on
The side that closes first waits about 60 seconds in TIME_WAIT before
fully releasing the connection. Two reasons:
- Catch stragglers. If the final ACK was lost, the other side will re-send
its FIN.
TIME_WAITis there to answer it rather than replying with a confusing RST. - Prevent confusion. Stop a brand-new connection using the same four numbers from receiving delayed packets belonging to the old one.
On Linux this is hardcoded at 60 seconds (
2 × MSL) and is not controlled bynet.ipv4.tcp_fin_timeout— that setting governs a different state (FIN_WAIT_2). This is a very common misconception; you'll see plenty of blog posts get it wrong.
Is a lot of TIME_WAIT bad? On a server, usually harmless. On a client making thousands of short connections per second, it can exhaust the ephemeral port range (~28,000 ports to a single destination). The real fix is connection reuse (keep-alive), not tuning kernel timers.
The states you'll actually see
ss -tan | awk '{print $1}' | sort | uniq -c | sort -rn
| State | Meaning | If you see lots of them |
|---|---|---|
LISTEN |
A server waiting for connections | Normal |
ESTAB |
Connection open, data can flow | Normal |
SYN-SENT |
Sent SYN, waiting for reply | Destination unreachable/firewalled |
SYN-RECV |
Got SYN, waiting for final ACK | Possible SYN flood, or clients disappearing |
TIME-WAIT |
Waiting out the 60 s after closing | Normal for busy clients; see above |
CLOSE-WAIT |
The peer closed; waiting for the local app to close | A bug in your application — it forgot to close the socket. Leaks file descriptors. |
FIN-WAIT-2 |
We closed; waiting for the peer to close | The peer app isn't closing properly |
The CLOSE_WAIT rule is worth memorising: growing CLOSE_WAIT means your
code isn't closing sockets. Growing FIN_WAIT_2 means their code isn't.
Practice (25 min)
A. Watch a full close:
sudo tcpdump -i any -n 'tcp port 443 and host example.com'
curl -s https://example.com > /dev/null
At the end of the capture, find the [F.] (FIN+ACK) packets and the final [.].
B. Watch a reset:
sudo tcpdump -i any -n 'tcp port 9999' &
nc -v 127.0.0.1 9999 # nothing is listening
You'll see [R.] — an RST.
C. See the state histogram on your own machine:
ss -tan | awk '{print $1}' | sort | uniq -c | sort -rn
Then open a lot of browser tabs and run it again.
D. Create TIME_WAIT deliberately:
for i in $(seq 1 20); do curl -s -o /dev/null http://example.com; done
ss -tan state time-wait | wc -l
# wait 60 seconds, run again -- they disappear
E. Find any CLOSE_WAIT on your system (and identify the guilty program):
ss -tanp state close-wait # Linux
lsof -i -sTCP:CLOSE_WAIT # macOS/Linux
If you find some that persist, you've found a real bug in some software.
Build it in Go (30 min) — manufacture a CLOSE_WAIT leak, then fix it
TIME_WAIT is normal. CLOSE_WAIT that keeps growing is always a bug in your
program: the peer has closed, the kernel has acknowledged its FIN, and the
socket is waiting for your code to call close(). It will wait forever. Each
leaked socket holds a file descriptor, and eventually the process hits its
open-files limit and stops accepting anything (too many open files).
// closewait: manufacture the CLOSE_WAIT leak from Chapter 23, watch it with
// ss/lsof, then fix it -- and see a clean half-close with CloseWrite.
//
// go run ./closewait -leak # server forgets to Close
// go run ./closewait # server closes properly
package main
import (
"bufio"
"flag"
"fmt"
"io"
"net"
"os"
"time"
)
func main() {
leak := flag.Bool("leak", false, "simulate the bug: never close accepted connections")
flag.Parse()
ln, err := net.Listen("tcp", "127.0.0.1:7070")
if err != nil {
fmt.Println(err)
os.Exit(1)
}
fmt.Printf("server pid %d on %s\n", os.Getpid(), ln.Addr())
fmt.Println("in another terminal run:")
fmt.Println(" Linux: watch -n1 \"ss -tan state close-wait '( sport = :7070 )'\"")
fmt.Println(" macOS: watch -n1 \"netstat -an -p tcp | grep 7070\"")
go func() {
for {
c, err := ln.Accept()
if err != nil {
return
}
go handle(c, *leak)
}
}()
// Ten clients connect, send a request, half-close, read the reply, leave.
for i := 0; i < 10; i++ {
c, err := net.Dial("tcp", ln.Addr().String())
if err != nil {
fmt.Println(err)
continue
}
fmt.Fprintf(c, "request %d\n", i)
c.(*net.TCPConn).CloseWrite() // send FIN: "I'm done talking, but still listening"
reply, _ := bufio.NewReader(c).ReadString('\n') // the server can still answer after our FIN
fmt.Printf("client %d got %q\n", i, reply)
c.Close()
}
fmt.Println("clients finished. Server stays up 60s so you can look; Ctrl-C to quit.")
time.Sleep(60 * time.Second)
}
func handle(c net.Conn, leak bool) {
req, _ := io.ReadAll(c) // returns when the client's FIN arrives (EOF)
fmt.Fprintf(c, "ok: %s", req)
if leak {
// BUG: no c.Close(). The client's FIN was acknowledged, so the kernel
// moves our side to CLOSE_WAIT and waits for US to close. It will wait
// forever, holding a file descriptor and kernel memory.
return
}
c.Close()
}
Real output on Linux:
$ go run ./closewait -leak &
$ ss -tan state close-wait '( sport = :7070 )'
Recv-Q Send-Q Local Address:Port Peer Address:Port
0 0 127.0.0.1:7070 127.0.0.1:55228
0 0 127.0.0.1:7070 127.0.0.1:55258
... <- 10 sockets, and they never go away
$ go run ./closewait # the fixed version
$ ss -tan state close-wait '( sport = :7070 )' | tail -n +2 | wc -l
0
What to notice:
- The client side is in
FIN_WAIT_2(runnetstat -an | grep 7070and you'll see both halves). The client sent its FIN and got an ACK, and is now waiting for the server's FIN, which never comes. CloseWrite()is a half-close: it sends FIN ("I've finished sending") but keeps reading. The server'sio.ReadAllreturns when that FIN arrives, and the server can still reply. HTTP/1.0 used exactly this pattern, and so donc -Nand many proxies.- In Go, the fix is almost always a missing
defer c.Close(), often on an error path, or aresp.Bodyfromnet/httpthat was never closed. That last one leaks the same way on the client side.
Exercises:
- While
-leakruns, check the file descriptors:ls /proc/$(pgrep closewait)/fd | wc -l. Then lower the limit withulimit -n 64and change the loop to 100 clients. What error do you get? (Linux guide, Chapter 43 coversulimit.) - Write the same bug with
net/http: a client that doesresp, _ := http.Get(url)and never closesresp.Body. Watch the connections pile up inESTABLISHEDinstead. Which side is now leaking? - The Go plan's Day 52 exercise has a production-style version with a leak detector. Do it next.
Common confusions
- "I have 20,000 TIME_WAIT sockets, is that bad?" Usually not on a server.
Check whether you're near port exhaustion before "fixing" it. Never enable
tcp_tw_recycle(it's removed from modern Linux and broke NAT users badly). - "Why does the connection stay around after I close the app?" TIME_WAIT is kernel state, not application state; it outlives the process deliberately.
- "FIN vs RST?" FIN = polite "I've finished, here's everything". RST = "stop immediately", data may be lost.
Check yourself
- Why does closing take four packets when opening takes three?
- Which side enters TIME_WAIT, and for roughly how long?
- What does a growing number of
CLOSE_WAITsockets tell you? - Does
tcp_fin_timeoutcontrol the TIME_WAIT duration?
Further reading
- Article (definitive): "Coping with the TCP TIME-WAIT state on busy Linux servers" — Vincent Bernat. Corrects most of the internet's bad advice on this topic. Read it if you ever tune a busy server.
- Video: "TCP Connection Termination" — Practical Networking.
- Diagram: search for the "TCP state transition diagram" (the classic one from RFC 793 / 9293). Print it and put it on your wall.
End of Part 5 — Milestone check
- I can explain the 4-tuple and why it identifies a connection
- I know the difference between binding to
0.0.0.0and127.0.0.1 - I can explain when UDP is the right choice
- I have captured a TCP three-way handshake and read it
- I can tell "refused" from "timeout" and know what each means
- I can explain sequence numbers, ACKs, and fast retransmit
- I know what
CLOSE_WAITpiling up means