Frontend Labs
⋯

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: ssthresh shows inf at first and cwnd climbs quickly. That's slow start. total_retrans stays at 0.
  • With 2% loss: total_retrans starts counting, ssthresh becomes a real number the first time loss is detected, and cwnd drops to around it and then grows more slowly. You're watching congestion control react.
  • rtt vs rttvar: a large variance relative to RTT means jitter. In testing, a lossy run started with rtt 814 ms and rttvar 480 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 and getsockopt calls that the net package doesn't wrap. (Calling File() instead duplicates the descriptor and puts it into blocking mode.)
  • The custom DialContext is 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 = inf means no loss has happened yet, so the connection is still in slow start (Chapter 25). After a loss, ssthresh is set to roughly half of cwnd, and the congestion window grows linearly from there.
  • Which side to measure: cwnd describes the sending side. On a download your client's cwnd stays 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

  • "ss vs netstat?" ss is the modern Linux replacement — faster and shows more. netstat is deprecated on Linux but still standard on macOS/BSD.
  • "Why can't I see other users' processes?" Use sudo.
  • "Recv-Q and Send-Q on a LISTEN socket?" On a listening socket these mean something different: Recv-Q is how many completed connections are waiting to be accepted, and Send-Q is the maximum (the backlog). If Recv-Q is at the max, your application isn't accepting fast enough.

Check yourself

  1. What does ss -tuln show?
  2. In ss -tin, what do rtt, cwnd, and retrans tell you?
  3. What does a large number of CLOSE-WAIT sockets indicate?
  4. 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 ss fits in the wider observability toolkit.

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