Chapter 33 — ss: what your machine's connections are doing
In one sentence
ss (or netstat on macOS) lists every socket on your machine — what's
listening, what's connected, and, with the right flags, detailed TCP health for
each connection.
The commands you'll use
ss -tuln # LISTENING TCP and UDP sockets (t=tcp, u=udp, l=listening, n=numeric)
ss -tunp # add the owning PROCESS (needs sudo for other users' sockets)
ss -tan # ALL TCP sockets with states
ss -tn state established # just the live connections
ss -s # a summary count by state
ss -tin # ** detailed TCP internals -- the good one **
ss -tan '( dport = :443 )' # filter
macOS equivalents:
netstat -an | grep LISTEN
netstat -an -p tcp
sudo lsof -iTCP -sTCP:LISTEN -n -P # with process names
The most useful command: ss -tin
ESTAB 0 0 192.168.1.50:51000 93.184.216.34:443
cubic wscale:7,7 rto:204 rtt:3.5/1.2 mss:1448 cwnd:32
bytes_sent:5120 bytes_acked:5120 bytes_received:104832
send 105.9Mbps retrans:0/2 rcv_rtt:4 rcv_space:65535
| Field | Meaning |
|---|---|
cubic |
which congestion control algorithm (Chapter 25) |
wscale:7,7 |
window scaling active (Chapter 24) — good |
rtt:3.5/1.2 |
measured round-trip time / variation, in ms |
rto:204 |
retransmission timeout (Chapter 22) |
cwnd:32 |
congestion window, in packets (Chapter 25) |
retrans:0/2 |
currently unrecovered / total retransmits this connection |
send 105.9Mbps |
the kernel's own estimate of this connection's rate |
mss:1448 |
max segment size in use (Chapter 15) |
This one command answers "why is this connection slow?" more often than
anything else: check rtt (distance), retrans (loss), cwnd (congestion), and
wscale (is scaling on?).
The state histogram: a 5-second health check
ss -tan | awk '{print $1}' | sort | uniq -c | sort -rn
1247 ESTAB <- normal
340 TIME-WAIT <- normal for a busy client
18 LISTEN <- your services
4 SYN-SENT <- trying to connect somewhere (is it failing?)
92 CLOSE-WAIT <- ** SUSPICIOUS: an app isn't closing sockets **
Refer back to Chapter 23 for what each state means. This histogram is the fastest way to spot a socket leak.
Practice (25 min)
A. Audit what's listening on your machine:
ss -tulnp # Linux
sudo lsof -iTCP -sTCP:LISTEN -n -P # macOS
For each entry, ask: what is this, and should it be reachable from the network?
Anything on 0.0.0.0 is exposed to your local network. Do this on any server you
run.
B. Watch connections appear and disappear:
watch -n1 'ss -tn state established | head -20'
Then browse the web and watch them come and go.
C. Investigate one connection in depth:
# start a big download
curl -o /dev/null https://speed.cloudflare.com/__down?bytes=100000000 &
# watch its internals
watch -n0.5 "ss -tin | grep -A2 cloudflare"
Watch cwnd grow (slow start!), rtt settle, and retrans hopefully stay at 0.
D. Find the process using a port:
sudo ss -tulnp | grep :8000 # Linux
sudo lsof -i :8000 # macOS/Linux
This is the answer to "address already in use".
E. Check the kernel's TCP counters:
nstat -az | grep -Ei 'retrans|timeout|listendrop|listenoverflow' # Linux
netstat -s -p tcp | head -30 # macOS
ListenOverflows climbing means a server's accept queue is full — a real and
often-missed cause of intermittent connection failures.
Build it in Go (30 min, Linux) — ss -ti from inside your program
ss -tin reads its numbers from the kernel with getsockopt(TCP_INFO). Your
own program can make the same call on its own sockets and log the kernel's
view of each connection: RTT, congestion window, retransmissions. That turns
"the network is slow" into numbers. This lab uploads 40 MB through Go's
HTTP client and samples TCP_INFO every 250 ms.
//go:build linux
// tcpinfo: read the kernel's per-connection TCP state (the same data `ss -ti`
// shows) from inside your own program while it UPLOADS -- the sender is the
// side whose congestion window (cwnd) grows and shrinks.
//
// go run ./tcpinfo https://speed.cloudflare.com/__up
// # then add loss and run again: sudo tc qdisc add dev eth0 root netem loss 2%
package main
import (
"context"
"fmt"
"io"
"net"
"net/http"
"os"
"syscall"
"time"
"unsafe"
)
func main() {
if len(os.Args) != 2 {
fmt.Println("usage: tcpinfo URL")
os.Exit(2)
}
conns := make(chan *net.TCPConn, 1)
tr := &http.Transport{
ForceAttemptHTTP2: false,
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
c, err := (&net.Dialer{}).DialContext(ctx, network, addr)
if tc, ok := c.(*net.TCPConn); ok {
conns <- tc // keep a handle on the raw TCP socket under the HTTP client
}
return c, err
},
}
const size = 40 << 20 // 40 MB of zeros
done := make(chan error, 1)
go func() {
resp, err := (&http.Client{Transport: tr}).Post(os.Args[1], "application/octet-stream",
io.LimitReader(zeros{}, size))
if err == nil {
resp.Body.Close()
}
done <- err
}()
tc := <-conns
fmt.Println(" time rtt(ms) rttvar cwnd(segs) ssthresh unacked lost total_retrans")
start := time.Now()
tick := time.NewTicker(250 * time.Millisecond)
defer tick.Stop()
for {
select {
case err := <-done:
fmt.Printf("done in %v (err=%v)\n", time.Since(start).Round(time.Millisecond), err)
return
case <-tick.C:
ti, err := getTCPInfo(tc)
if err != nil {
fmt.Println("getsockopt:", err)
return
}
ssthresh := fmt.Sprint(ti.Snd_ssthresh)
if ti.Snd_ssthresh >= 1<<30 {
ssthresh = "inf" // no loss seen yet: still in slow start
}
fmt.Printf("%6.2fs %7.2f %6.2f %10d %8s %7d %4d %13d\n",
time.Since(start).Seconds(), float64(ti.Rtt)/1000, float64(ti.Rttvar)/1000,
ti.Snd_cwnd, ssthresh, ti.Unacked, ti.Lost, ti.Total_retrans)
}
}
}
// getTCPInfo calls getsockopt(fd, IPPROTO_TCP, TCP_INFO). SyscallConn gives
// us the file descriptor without taking it away from Go's network poller.
func getTCPInfo(c *net.TCPConn) (*syscall.TCPInfo, error) {
raw, err := c.SyscallConn()
if err != nil {
return nil, err
}
var info syscall.TCPInfo
var sysErr error
err = raw.Control(func(fd uintptr) {
size := uint32(syscall.SizeofTCPInfo)
_, _, e := syscall.Syscall6(syscall.SYS_GETSOCKOPT, fd,
syscall.IPPROTO_TCP, syscall.TCP_INFO,
uintptr(unsafe.Pointer(&info)), uintptr(unsafe.Pointer(&size)), 0)
if e != 0 {
sysErr = e
}
})
if err != nil {
return nil, err
}
return &info, sysErr
}
type zeros struct{}
func (zeros) Read(p []byte) (int, error) { clear(p); return len(p), nil }
$ go run ./tcpinfo https://speed.cloudflare.com/__up
time rtt(ms) rttvar cwnd(segs) ssthresh unacked lost total_retrans
...one row every 250 ms...
$ sudo tc qdisc add dev eth0 root netem loss 2% # then run it again
Run it on a real Linux host or VM. Inside Docker Desktop on a Mac, the connection is proxied by the VM's network layer, so RTT and cwnd describe a hop to the proxy, not the path to Cloudflare. What to look for:
- Without loss:
ssthreshshowsinfat first andcwndclimbs quickly. That's slow start.total_retransstays at 0. - With 2% loss:
total_retransstarts counting,ssthreshbecomes a real number the first time loss is detected, andcwnddrops to around it and then grows more slowly. You're watching congestion control react. rttvsrttvar: a large variance relative to RTT means jitter. In testing, a lossy run started withrtt814 ms andrttvar480 ms: the first samples included retransmission delays.
What to notice:
SyscallConn().Control()hands you the raw file descriptor without taking it away from Go's runtime. It's the safe escape hatch for socket options andgetsockoptcalls that thenetpackage doesn't wrap. (CallingFile()instead duplicates the descriptor and puts it into blocking mode.)- The custom
DialContextis how you get hold of the TCP socket under a high-level HTTP client. The same hook is used for custom DNS, SOCKS proxies, and the SSRF protection in the HTTPS guide. ssthresh = infmeans no loss has happened yet, so the connection is still in slow start (Chapter 25). After a loss,ssthreshis set to roughly half ofcwnd, and the congestion window grows linearly from there.- Which side to measure:
cwnddescribes the sending side. On a download your client'scwndstays at 10 because it hardly sends anything. To study a slow download, measure on the server.
Exercise: make it a library. Write func LogTCPInfo(c net.Conn, every time.Duration)
and call it from a real service on its slowest outbound connection, emitting
rtt, cwnd, and total_retrans as structured log fields
(Go guide: log/slog). When someone next says "the database
is slow", you'll know whether the network was involved.
Common confusions
- "
ssvsnetstat?"ssis the modern Linux replacement — faster and shows more.netstatis deprecated on Linux but still standard on macOS/BSD. - "Why can't I see other users' processes?" Use
sudo. - "
Recv-QandSend-Qon a LISTEN socket?" On a listening socket these mean something different:Recv-Qis how many completed connections are waiting to be accepted, andSend-Qis the maximum (the backlog). IfRecv-Qis at the max, your application isn't accepting fast enough.
Check yourself
- What does
ss -tulnshow? - In
ss -tin, what dortt,cwnd, andretranstell you? - What does a large number of
CLOSE-WAITsockets indicate? - How do you find which process is using port 8080?
Further reading
- Man pages:
man ss— genuinely good, with examples. - Article: search "ss command examples" — many good cheat sheets.
- Blog: Brendan Gregg's Linux performance tools posts (brendangregg.com) —
where
ssfits in the wider observability toolkit.