Frontend Labs
⋯

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:

  1. Catch stragglers. If the final ACK was lost, the other side will re-send its FIN. TIME_WAIT is there to answer it rather than replying with a confusing RST.
  2. 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 by net.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 (run netstat -an | grep 7070 and 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's io.ReadAll returns when that FIN arrives, and the server can still reply. HTTP/1.0 used exactly this pattern, and so do nc -N and many proxies.
  • In Go, the fix is almost always a missing defer c.Close(), often on an error path, or a resp.Body from net/http that was never closed. That last one leaks the same way on the client side.

Exercises:

  1. While -leak runs, check the file descriptors: ls /proc/$(pgrep closewait)/fd | wc -l. Then lower the limit with ulimit -n 64 and change the loop to 100 clients. What error do you get? (Linux guide, Chapter 43 covers ulimit.)
  2. Write the same bug with net/http: a client that does resp, _ := http.Get(url) and never closes resp.Body. Watch the connections pile up in ESTABLISHED instead. Which side is now leaking?
  3. 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

  1. Why does closing take four packets when opening takes three?
  2. Which side enters TIME_WAIT, and for roughly how long?
  3. What does a growing number of CLOSE_WAIT sockets tell you?
  4. Does tcp_fin_timeout control 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.0 and 127.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_WAIT piling up means

Generated from real-life-guide-v1.md · 8 Oct 2026 16:36