Skip to main content

ss, netstat & curl

tcpdump shows packets on the wire; ss/netstat show the connection table your own machine is keeping, and curl shows exactly how long each phase of a single HTTP request took. Together they answer "what is this host doing right now" without needing a packet capture at all.

ss and netstat: connection state and listening ports

ss (socket statistics) is the modern replacement for netstat, and is generally faster on a host with many connections since it reads directly from kernel tables instead of /proc. The flags map almost one-to-one:

$ ss -tan
State Recv-Q Send-Q Local Address:Port Peer Address:Port
LISTEN 0 128 0.0.0.0:22 0.0.0.0:*
LISTEN 0 128 0.0.0.0:443 0.0.0.0:*
ESTAB 0 0 10.0.0.7:51322 10.0.0.5:443
TIME-WAIT 0 0 10.0.0.7:51410 93.184.216.34:443
SYN-SENT 0 1 10.0.0.7:51488 198.51.100.9:443
$ netstat -tan
Active Internet connections (servers and established)
Proto Recv-Q Send-Q Local Address Foreign Address State
tcp 0 0 0.0.0.0:22 0.0.0.0:* LISTEN
tcp 0 0 0.0.0.0:443 0.0.0.0:* LISTEN
tcp 0 0 10.0.0.7:51322 10.0.0.5:443 ESTABLISHED
tcp 0 0 10.0.0.7:51410 93.184.216.34:443 TIME_WAIT
tcp 0 1 10.0.0.7:51488 198.51.100.9:443 SYN_SENT

-t restricts to TCP, -a shows all sockets (not just established), -n prints numeric addresses/ports instead of resolving them (much faster, and avoids DNS lookups skewing the output).

The states you'll see most often while debugging (see TCP States in Practice for the full state machine and TIME-WAIT exhaustion in depth):

StateMeaning
LISTENA process is bound to this port, waiting for incoming connections
SYN-SENTWe sent a SYN and are waiting for SYN-ACK - stuck here means the peer isn't responding
ESTABLISHEDHandshake complete, connection is active
TIME-WAITConnection closed locally, kept around briefly to catch any late/duplicate packets
CLOSE-WAITPeer closed their side; we haven't closed ours yet - many of these piling up usually means the application is leaking connections
Gotcha

A large number of connections stuck in SYN-SENT almost always means something between you and the destination is silently dropping SYNs (a firewall rule, security group, or a dead route) rather than the destination actively refusing - an active refusal comes back as a reset immediately, not a hang. A pile of CLOSE-WAIT connections is an application bug: the local process is not calling close() after the peer hangs up.

curl -v and curl -w: timing a single request

ss/netstat show the connection table; curl shows exactly how long one request took, broken into phases. -v (verbose) prints the request/response headers and the TLS handshake:

$ curl -v https://example.com/ -o /dev/null
* Trying 93.184.216.34:443...
* Connected to example.com (93.184.216.34) port 443
* ALPN: curl offers h2,http/1.1
* TLSv1.3 (OUT), TLS handshake, Client hello
* TLSv1.3 (IN), TLS handshake, Server hello
* TLSv1.3 (IN), TLS handshake, Certificate
* SSL connection using TLSv1.3 / TLS_AES_256_GCM_SHA384
* ALPN: server accepted h2
> GET / HTTP/2
> Host: example.com
> user-agent: curl/8.4.0
> accept: */*
>
< HTTP/2 200
< content-type: text/html
< content-length: 1256

For the actual phase timing, -w with the built-in timing variables gives exact numbers instead of eyeballing -v output:

$ curl -o /dev/null -s -w \
'dns: %{time_namelookup}s connect: %{time_connect}s tls: %{time_appconnect}s ttfb: %{time_starttransfer}s total: %{time_total}s\n' \
https://example.com/
 
dns: 0.021s connect: 0.034s tls: 0.089s ttfb: 0.112s total: 0.113s

Each variable is cumulative from the start of the request, so subtract consecutive ones to get that phase's individual cost:

VariableCumulative time toIsolate the phase by
time_namelookupDNS resolution completingvalue itself (first phase)
time_connectTCP connection establishedtime_connect - time_namelookup
time_appconnectTLS handshake completingtime_appconnect - time_connect
time_starttransferfirst byte of the response body (TTFB)time_starttransfer - time_appconnect
time_totalentire request/response cycletime_total - time_starttransfer
tip

A high time_namelookup alone points at DNS (see dig and DNS Debugging); a high time_connect - time_namelookup points at network path/TCP; a high time_appconnect - time_connect points at TLS/certificate issues; a high time_starttransfer - time_appconnect points at the server being slow to produce a response, not the network at all.

curl --resolve: bypassing DNS entirely

--resolve lets you force a hostname to a specific IP for one request, without touching /etc/hosts or DNS - the cleanest way to test "is it the new server that's broken, or DNS pointing somewhere stale":

$ curl --resolve example.com:443:198.51.100.9 https://example.com/ -v
* Added example.com:443:198.51.100.9 to DNS cache
* Trying 198.51.100.9:443...
* Connected to example.com (198.51.100.9) port 443

This also correctly sends the real hostname in the TLS SNI and the Host header, so it works against servers that route by name (unlike just curling the IP directly, which would fail SNI-based virtual hosting or certificate validation).

info

--resolve is the tool for validating a new server before changing DNS to point at it, and for confirming a "works on one machine" report is a stale-DNS problem rather than something wrong with the new server itself.